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;
18mod axel_context;
19#[cfg(test)]
20mod body_golden;
21pub mod budget;
22mod cache_diagnosis;
23pub mod capture_worker;
24pub mod chat_capture;
25pub(crate) mod cloud_invoke;
26pub mod compaction;
27pub mod context;
28pub mod continuation;
29pub mod google_gemini;
30pub mod google_vertex;
31pub(crate) mod helpers;
32mod http_phases;
33mod image_limits;
34mod image_retention;
35pub mod keepwarm;
36pub mod memory_context;
37pub mod memory_history;
38pub mod openai;
39pub mod relay;
40mod request;
41mod sse;
42mod sse_types;
43mod stream;
44pub mod subagent;
45pub mod subagent_watch;
46pub mod telemetry;
47pub mod trace;
48pub(crate) mod transport;
49mod types;
50
51use api::ApiMethods;
52use auth::AuthMethods;
53use helpers::HelperMethods;
54use stream::StreamMethods;
55use types::AuthState;
56pub use types::{AgentEvent, CancelCause, LlmEvent, SessionEvent, StreamEvent, TurnCompletion};
57pub use stream::activation_policy;
58
59/// Result of resolving before_tool_call extension policy.
60pub enum BeforeToolCallDecision {
61    Continue { input: Value },
62    Block { reason: String },
63}
64
65/// Emit a `before_tool_call` event and include the runtime tool name when it
66/// differs from the API-safe name. `session_id` is the owning conversation
67/// (`None` for workers → JSON `null`, unchanged).
68pub async fn emit_before_tool_call(
69    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
70    tool_name: &str,
71    runtime_tool_name: Option<&str>,
72    input: Value,
73    session_id: Option<&str>,
74) -> crate::extensions::hooks::events::HookResult {
75    let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input)
76        .with_session(session_id);
77    if let Some(runtime_tool_name) = runtime_tool_name {
78        event.tool_runtime_name = Some(runtime_tool_name.to_string());
79    }
80    hook_bus.emit(&event).await
81}
82
83/// Answer the Confirm dialog sends for its "Allow all from <ext>" button.
84/// `resolve_before_tool_call_result` treats it as an allow AND latches the
85/// confirm's source extension(s) in the session's
86/// [`Runtime::session_allow_all`] set, so later Confirm results from those
87/// extensions (only) are auto-approved without a prompt.
88pub use crate::tools::CONFIRM_ANSWER_ALLOW_ALL;
89
90/// Longest extension `message` shown in a Confirm prompt (chars / lines).
91/// The host-built header and the dialog's buttons and scope line must stay
92/// visible, so a long or hostile message is cut with a marker.
93const MAX_CONFIRM_MESSAGE_CHARS: usize = 1200;
94const MAX_CONFIRM_MESSAGE_LINES: usize = 12;
95
96/// Sanitize, redact and cap an extension's confirm message for display:
97/// control / bidi / zero-width characters are stripped first (prA I-R4-3:
98/// they could move the cursor or disguise text in plain-text clients),
99/// then credentials are redacted (prA I-R4-1: confirmers echo the
100/// rewritten command), then the length is capped.
101fn cap_confirm_message(message: &str) -> String {
102    let message = redact_secret_text(&crate::session::sanitize_prompt_text(message));
103    let lines: Vec<&str> = message.lines().collect();
104    let over_lines = lines.len() > MAX_CONFIRM_MESSAGE_LINES;
105    let over_chars = message.chars().count() > MAX_CONFIRM_MESSAGE_CHARS;
106    if !over_lines && !over_chars {
107        return message;
108    }
109    let kept = lines[..lines.len().min(MAX_CONFIRM_MESSAGE_LINES)].join("\n");
110    let kept: String = kept.chars().take(MAX_CONFIRM_MESSAGE_CHARS).collect();
111    format!("{kept}\n…[message truncated by host]")
112}
113
114/// Lines / chars of the final-input preview shown when the input was
115/// rewritten by an earlier extension.
116const MAX_REWRITE_PREVIEW_LINES: usize = 6;
117const MAX_REWRITE_PREVIEW_CHARS: usize = 400;
118
119/// Scrub credential shapes out of free text (a shell command, a header
120/// line, an extension's confirm message) for the Confirm prompt only. Uses
121/// the AGGRESSIVE `trace::export::redact_command_text` (the shared
122/// high-precision rules plus recall-first ones). Stored text the model or
123/// user reads back (context archive, memory, traces) keeps the precise
124/// shared scrub (prA I-R5-1). The prompt is broadcast to every attached
125/// client (observers included) and replayed on attach, so it must never
126/// show a secret.
127fn redact_secret_text(text: &str) -> String {
128    crate::runtime::trace::export::redact_command_text(text)
129}
130
131/// `redact_value` (secret-named keys, secret-shaped strings) plus
132/// [`redact_secret_text`] on every remaining string, recursively.
133fn redact_preview_value(value: &mut Value) {
134    crate::runtime::trace::export::redact_value(value);
135    match value {
136        Value::Object(map) => map.values_mut().for_each(redact_preview_value),
137        Value::Array(items) => items.iter_mut().for_each(redact_preview_value),
138        Value::String(s) => *s = redact_secret_text(s),
139        _ => {}
140    }
141}
142
143/// Host-rendered, capped, REDACTED preview of the input that will actually
144/// run: a shell-style `command` string as-is, anything else as pretty JSON.
145fn final_input_preview(input: &Value) -> String {
146    let mut input = input.clone();
147    redact_preview_value(&mut input);
148    let full = match input.get("command").and_then(Value::as_str) {
149        Some(cmd) => cmd.to_string(),
150        None => serde_json::to_string_pretty(&input).unwrap_or_else(|_| input.to_string()),
151    };
152    let lines: Vec<&str> = full.lines().collect();
153    let mut out: String = lines[..lines.len().min(MAX_REWRITE_PREVIEW_LINES)]
154        .iter()
155        .map(|l| format!("  {l}"))
156        .collect::<Vec<_>>()
157        .join("\n");
158    let mut cut = lines.len() > MAX_REWRITE_PREVIEW_LINES;
159    if out.chars().count() > MAX_REWRITE_PREVIEW_CHARS {
160        out = out.chars().take(MAX_REWRITE_PREVIEW_CHARS).collect();
161        cut = true;
162    }
163    if cut {
164        out.push_str("\n  …[preview truncated]");
165    }
166    out
167}
168
169/// Prompt body for an extension tool-call Confirm: the host-known source and
170/// tool lead, so an extension's own `message` cannot hide who is asking.
171/// When earlier extensions rewrote the input (prA N3) the header also says
172/// so, by whom, and previews the FINAL input — the one approval runs —
173/// before the extension's own message (which is cut first on small screens).
174fn confirm_prompt_body(
175    message: &str,
176    sources: &[String],
177    tool: Option<&str>,
178    rewrite: Option<(&[String], &Value)>,
179) -> String {
180    let source = if sources.is_empty() {
181        "(unknown)".to_string()
182    } else {
183        sources.join(", ")
184    };
185    let rewritten = match rewrite {
186        Some((by, input)) => format!(
187            "Input rewritten by: {}\nInput that will run:\n{}\n",
188            if by.is_empty() { "(unknown)".to_string() } else { by.join(", ") },
189            final_input_preview(input)
190        ),
191        None => String::new(),
192    };
193    // The extension's own text is labelled, so it can't pass itself off as
194    // the host-built header (e.g. a fake "Input that will run:" block).
195    // Ids and tool names are host-chosen, but sanitize the whole body too.
196    crate::session::sanitize_prompt_text(&format!(
197        "Extension: {source}\nTool: {}\n{rewritten}\nMessage from {source}:\n{}\n\nAllow this tool call?",
198        tool.unwrap_or("(unknown)"),
199        cap_confirm_message(message)
200    ))
201}
202
203/// What an approved Confirm runs: the input as modified earlier in the hook
204/// chain, if any (`Modify`), else the original (`Continue`).
205fn approved(
206    modified_input: Option<Value>,
207) -> crate::extensions::hooks::events::HookResult {
208    match modified_input {
209        Some(input) => crate::extensions::hooks::events::HookResult::Modify { input },
210        None => crate::extensions::hooks::events::HookResult::Continue,
211    }
212}
213
214/// Resolve a before_tool_call result that may request user confirmation.
215///
216/// When `auto_approve_confirms` is true, or every source extension of the
217/// Confirm (same load generation) has been latched by an earlier "Allow
218/// all" answer, `Confirm` is short-circuited to approval. Headless /
219/// non-interactive callers with neither fail closed. "Allow all" is only
220/// offered (a `ConfirmScope` is attached) when this call site has a latch
221/// to arm; otherwise the dialog offers Allow / Deny only.
222pub async fn resolve_before_tool_call_result(
223    hook_result: crate::extensions::hooks::events::HookResult,
224    secret_prompt: Option<&crate::tools::SecretPromptHandle>,
225    auto_approve_confirms: bool,
226    session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
227) -> crate::extensions::hooks::events::HookResult {
228    match hook_result {
229        crate::extensions::hooks::events::HookResult::Confirm {
230            message,
231            sources,
232            generations,
233            tool,
234            modified_input,
235            modified_by,
236        } => {
237            let keys = crate::tools::latch_keys(&sources, &generations);
238            if auto_approve_confirms {
239                tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
240                return approved(modified_input);
241            }
242            if session_allow_all.is_some_and(|latch| latch.allows_all(&keys)) {
243                tracing::info!(
244                    message = %message,
245                    extensions = ?sources,
246                    "confirm auto-approved (extension allow-all latched)"
247                );
248                return approved(modified_input);
249            }
250
251            let Some(prompt) = secret_prompt else {
252                return crate::extensions::hooks::events::HookResult::Block {
253                    reason: format!(
254                        "Tool call requires confirmation but no interactive prompt is available: {}",
255                        redact_secret_text(&crate::session::sanitize_prompt_text(&message))
256                    ),
257                };
258            };
259
260            // No latch on this path → no scope → no Allow-all button, so the
261            // dialog and the "armed" notice never promise what can't happen.
262            let scope = (session_allow_all.is_some() && !sources.is_empty()).then(|| {
263                crate::session::ConfirmScope {
264                    extensions: sources.clone(),
265                    tool: tool.clone(),
266                    rewritten_by: if modified_input.is_some() {
267                        modified_by.clone()
268                    } else {
269                        Vec::new()
270                    },
271                }
272            });
273            let response = prompt
274                .prompt_scoped(
275                    crate::session::CONFIRM_PROMPT_TITLE.to_string(),
276                    confirm_prompt_body(
277                        &message,
278                        &sources,
279                        tool.as_deref(),
280                        modified_input.as_ref().map(|i| (modified_by.as_slice(), i)),
281                    ),
282                    scope,
283                )
284                .await;
285
286            match response.as_deref().map(str::trim) {
287                Some(answer)
288                    if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
289                {
290                    approved(modified_input)
291                }
292                Some(answer) if answer.eq_ignore_ascii_case(CONFIRM_ANSWER_ALLOW_ALL) => {
293                    match session_allow_all {
294                        Some(latch) if latch.allow(&keys) => tracing::warn!(
295                            extensions = ?sources,
296                            "user chose Allow all: confirms from these extensions are auto-approved for the rest of this conversation"
297                        ),
298                        Some(_) => tracing::warn!(
299                            "user chose Allow all on an unattributed or revoked confirm; approving this call only"
300                        ),
301                        None => tracing::warn!(
302                            "user chose Allow all but this path has no session latch; approving this call only"
303                        ),
304                    }
305                    approved(modified_input)
306                }
307                _ => crate::extensions::hooks::events::HookResult::Block {
308                    reason: format!(
309                        "Tool call confirmation denied: {}",
310                        redact_secret_text(&crate::session::sanitize_prompt_text(&message))
311                    ),
312                },
313            }
314        }
315        other => other,
316    }
317}
318
319/// Resolve before_tool_call policy into executable input or a block reason.
320pub async fn resolve_before_tool_call_decision(
321    original_input: Value,
322    hook_result: crate::extensions::hooks::events::HookResult,
323    secret_prompt: Option<&crate::tools::SecretPromptHandle>,
324    auto_approve_confirms: bool,
325    session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
326) -> BeforeToolCallDecision {
327    match resolve_before_tool_call_result(
328        hook_result,
329        secret_prompt,
330        auto_approve_confirms,
331        session_allow_all,
332    )
333    .await
334    {
335        crate::extensions::hooks::events::HookResult::Block { reason } => {
336            BeforeToolCallDecision::Block { reason }
337        }
338        crate::extensions::hooks::events::HookResult::Modify { input } => {
339            BeforeToolCallDecision::Continue { input }
340        }
341        _ => BeforeToolCallDecision::Continue {
342            input: original_input,
343        },
344    }
345}
346
347/// Maximum size (bytes) of a `Replace` output an extension may substitute.
348/// A transform returning more than this is clamped (with a warning) to bound
349/// memory between deserialization and the downstream tool-output truncation.
350/// Generous on purpose — legitimate compression/redaction shrinks output.
351const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; // 1 MiB
352
353/// Emit an `after_tool_call` event and return the tool output to record in
354/// history — either the original `output`, or a `Replace { output }`
355/// substituted by an extension transform hook (compression, redaction,
356/// summarization). The runtime tool name is included when it differs from the
357/// API-safe name.
358///
359/// Fail-safe: any non-`Replace` result — `Continue`, a result rejected by the
360/// permission matrix, a crashed/timed-out handler, or any future variant —
361/// returns the original `output` unchanged. A misbehaving extension can never
362/// drop or corrupt a tool's output.
363///
364/// Note: the event delivered to the extension carries `tool_output` as a
365/// size-limited preview for large outputs — a ~256 KB prefix plus a
366/// `…[truncated, N total bytes]` marker (see [`HookEvent::after_tool_call`]),
367/// matching `max_tool_buffer`. The final returned string is then truncated to
368/// `max_tool_output` (the context budget) — compress-then-truncate ordering,
369/// so a transform extension sees the full buffered output and decides what to
370/// keep before the hard cap is applied.
371pub async fn emit_after_tool_call(
372    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
373    tool_name: &str,
374    runtime_tool_name: Option<&str>,
375    input: Value,
376    output: String,
377    max_tool_output: usize,
378    session_id: Option<&str>,
379) -> String {
380    emit_after_tool_call_outcome(
381        hook_bus,
382        tool_name,
383        runtime_tool_name,
384        input,
385        output,
386        max_tool_output,
387        session_id,
388    )
389    .await
390    .output
391}
392
393/// Distinguish an authoritative hook replacement from ordinary budget truncation.
394/// Byte equality with the summary cannot establish whether the delta lane is stale.
395pub(super) struct AfterToolCallOutcome {
396    pub output: String,
397    pub replaced: bool,
398}
399
400pub(super) async fn emit_after_tool_call_outcome(
401    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
402    tool_name: &str,
403    runtime_tool_name: Option<&str>,
404    input: Value,
405    output: String,
406    max_tool_output: usize,
407    session_id: Option<&str>,
408) -> AfterToolCallOutcome {
409    use crate::extensions::hooks::events::HookResult;
410    // Keep the original to return verbatim if no transform fires.
411    let original = output.clone();
412    let mut event =
413        crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output)
414            .with_session(session_id);
415    if let Some(runtime_tool_name) = runtime_tool_name {
416        event.tool_runtime_name = Some(runtime_tool_name.to_string());
417    }
418    let mut replaced = false;
419    let post_hook = match hook_bus.emit(&event).await {
420        HookResult::Replace { mut output } => {
421            replaced = true;
422            if output.len() > MAX_REPLACE_OUTPUT {
423                tracing::warn!(
424                    tool = %tool_name,
425                    len = output.len(),
426                    cap = MAX_REPLACE_OUTPUT,
427                    "Extension Replace output exceeds cap — clamping",
428                );
429                // Floor to a UTF-8 char boundary before truncating.
430                let mut boundary = MAX_REPLACE_OUTPUT;
431                while boundary > 0 && !output.is_char_boundary(boundary) {
432                    boundary -= 1;
433                }
434                output.truncate(boundary);
435            }
436            output
437        }
438        HookResult::Continue => original,
439        other => {
440            tracing::warn!(
441                tool = %tool_name,
442                ?other,
443                "Unexpected after_tool_call result — using original output",
444            );
445            original
446        }
447    };
448    // Compress-then-truncate: apply the context-budget cap AFTER the hook.
449    // Mirrors `HelperMethods::truncate_tool_result` byte-for-byte so the
450    // no-extension path is behavior-identical to the legacy ordering.
451    AfterToolCallOutcome {
452        output: crate::runtime::helpers::HelperMethods::truncate_tool_result(
453            &post_hook,
454            max_tool_output,
455        ),
456        replaced,
457    }
458}
459
460/// A reasoning-level substitution performed during a model change because the
461/// newly selected model does not support the previously active level.
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub struct ReasoningClamp {
464    pub from: agent_core::reasoning::ReasoningLevel,
465    pub to: agent_core::reasoning::ReasoningLevel,
466}
467
468fn validated_single_tool_output(
469    model: &str,
470    output: crate::ToolOutput,
471) -> (String, Option<Vec<Value>>) {
472    let (summary, blocks) = output.into_parts();
473    if let Some(ref blocks) = blocks {
474        if let Err(error) = attachments::validate_tool_blocks(model, blocks) {
475            return (format!("Attachment not sent: {error}"), None);
476        }
477    }
478    (summary, blocks)
479}
480
481/// A summary rewrite (including the post-hook truncation) invalidates rich
482/// blocks: retaining them would bypass the hook's replacement/redaction.
483fn retain_single_tool_blocks(
484    blocks: Option<Vec<Value>>,
485    original: &str,
486    hooked: &str,
487) -> Option<Vec<Value>> {
488    if original == hooked {
489        blocks
490    } else {
491        None
492    }
493}
494
495fn single_tool_result_content(
496    result: &str,
497    blocks: Option<Vec<Value>>,
498    max_tool_output: usize,
499) -> Value {
500    match blocks {
501        Some(blocks) => Value::Array(blocks),
502        None => Value::String(HelperMethods::truncate_tool_result(result, max_tool_output)),
503    }
504}
505
506/// The core runtime — manages API communication, tool execution, authentication,
507/// and streaming for all SynapsCLI binaries (chat, chatui, server, agent, watcher).
508#[derive(Clone)]
509struct PromptReloadSource {
510    manifest: PathBuf,
511    context: agent_core::prompt::SelectionContext,
512    user_module: Option<agent_core::prompt::PromptModule>,
513    delegation_policy_digest: Option<String>,
514}
515
516pub struct Runtime {
517    client: Client,
518    anthropic_stream_client: Arc<std::sync::OnceLock<Client>>,
519    auth: Arc<RwLock<AuthState>>,
520    model: String,
521    tools: Arc<RwLock<ToolRegistry>>,
522    system_prompt: Option<String>,
523    /// Compiled, content-safe effective prompt metadata retained for inspection/reload.
524    effective_prompt: Option<agent_core::prompt::PromptStack>,
525    prompt_generation: u64,
526    /// Inputs retained from boot so callable reloads compile the same manifest selection.
527    prompt_reload_source: Option<PromptReloadSource>,
528    thinking_budget: u32,
529    /// Named reasoning level — canonical for Codex and future providers.
530    /// When set, overrides what `thinking_level()` returns. For legacy
531    /// Anthropic models this is derived from `thinking_budget` at read time.
532    named_level: Option<agent_core::reasoning::ReasoningLevel>,
533    /// True when the user has explicitly chosen a reasoning level (via command
534    /// or config). False for derived/default values applied by set_model or
535    /// session restore. Controls whether set_model overwrites the level with
536    /// the new model's default.
537    explicit_reasoning: bool,
538    /// Foreground by default; central subagent construction marks Worker so
539    /// logical Ultra can never recursively activate proactive orchestration.
540    codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
541    /// User override for context window size (tokens). When set, takes
542    /// precedence over the model's auto-detected window from
543    /// `models::context_window_for_model`. Lets users cap context at e.g.
544    /// 200k even on models that natively support 1M.
545    context_window_override: Option<u64>,
546    /// Opt-in context windows within a stable logical session (#112).
547    /// DARK by default: `ContextManagementMode::Off`.
548    pub(crate) continuation: continuation::SharedContinuation,
549    /// Model used for compaction. Falls back to claude-sonnet-4-6 if not set.
550    compaction_model: Option<String>,
551    /// Where compaction summarization runs (spec §9.4).
552    compaction_mode: agent_core::compaction::CompactionMode,
553    /// Content classes excluded from remote compaction disclosure.
554    compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
555    /// Transport-construction seam (spec §9.4 / CP-12 M3): incremented at
556    /// the SINGLE remote-summarization entry point before any preflight,
557    /// auth, or HTTP request construction. Shared across clones so the
558    /// local-only zero-network proof observes every path.
559    remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
560    /// Shared registry for reactive subagent handles.
561    subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
562    /// Session-scoped orchestration enforcement installed during boot.
563    orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
564    /// Shared event queue — for Event Bus tooling.
565    event_queue: Arc<crate::events::EventQueue>,
566    /// Path for watcher_exit tool to write handoff state (agent mode only)
567    pub watcher_exit_path: Option<PathBuf>,
568    // New configurable fields
569    max_tool_output: usize,
570    bash_timeout: u64,
571    bash_max_timeout: u64,
572    subagent_timeout: u64,
573    api_retries: u32,
574    refusal_retries: u32,
575    /// Telemetry level for structured per-request API logging (opt-in).
576    telemetry_level: crate::runtime::telemetry::TelemetryLevel,
577    /// Session-shared bounded observability writer (Task 11, spec §6.5).
578    /// `Some` iff `telemetry_level` is Basic/Full. Cloned runtimes
579    /// (subagents) share the same handle — one worker per session, never
580    /// per request. Dropping the last handle detaches the worker (it drains
581    /// and exits on its own), so `Drop` can never hang.
582    telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
583    /// Trace context handed to every `ApiOptions` site. Enabled (writer
584    /// sink) iff `telemetry_writer` is `Some`; otherwise the no-op sink.
585    /// Shared across clones so all requests of the session carry the same
586    /// session-scoped context IDs.
587    trace_ctx: trace::TraceContext,
588    /// Explicit one-shot trace controls (Task 12): `/trace next` arms
589    /// exactly the next outgoing provider request, even when telemetry is
590    /// Off, then auto-disarms. Shared across clones.
591    trace_controls: std::sync::Arc<trace::TraceControls>,
592    /// Session-scoped continuous-memory context state (task A5, spec §7.3):
593    /// the single state machine every frontend's `/memory` command and the
594    /// task-A4 `memory_context` tool observe. Shared across `Clone`s —
595    /// clones are the same session, so all its streams see one truth — but
596    /// NEVER inherited by subagents: every subagent spawn path constructs a
597    /// brand-new `Runtime::new()` (then `apply_subagent_runtime_policy`,
598    /// see `tools/subagent/mod.rs`), and every `Runtime` constructor
599    /// initializes this slot to the Off/no-lease default via
600    /// [`fresh_memory_context_state`]. Do not add any code path that copies
601    /// memory-context state from a parent runtime into a freshly
602    /// constructed one.
603    memory_context_state: std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>>,
604    /// Immutable host-owned note backend and captured project scope (#112).
605    /// DARK by default: `MemoryBinding::legacy_current()`.
606    memory_backend: crate::memory_backend::MemoryBinding,
607    /// First explicit host selection; never replace the captured binding.
608    memory_backend_config: Option<crate::config::MemoryBackendConfig>,
609    /// Sticky across same-session clones: live extension policy needs restart.
610    memory_backend_reconfigure_denied: Arc<std::sync::atomic::AtomicBool>,
611    /// Production resolves the exact leased extension provider at dispatch.
612    /// Tests may install this in-process provider to observe the same worker
613    /// boundary without spawning an extension process.
614    #[cfg(any(test, feature = "testing"))]
615    capture_provider_for_test: std::sync::Arc<
616        std::sync::Mutex<Option<std::sync::Arc<dyn capture_worker::CaptureProvider>>>,
617    >,
618    /// Pending D1 disclosure awaiting an explicit `/memory index-history
619    /// confirm|decline` decision. Same-session clones share one pending preview;
620    /// fresh runtimes and subagents start with none.
621    pending_history_import_preview:
622        std::sync::Arc<std::sync::Mutex<Option<memory_history::HistoryImportPreview>>>,
623    /// The last explicitly confirmed D1 plan, retained for D2 to consume.
624    history_import_plan: std::sync::Arc<std::sync::Mutex<Option<memory_history::ImportPlan>>>,
625    /// The validated recall contribution held for the CURRENT turn, if any
626    /// (task A7, spec §10.1): a typed [`memory_context::ContextSegment`] —
627    /// never raw text — admitted only through the
628    /// [`memory_context::validate_contribution`] gate. It feeds the T29
629    /// context-budget `memory_contents` lane in [`Runtime::assess_context`];
630    /// as of task B4 the per-turn recall flow keeps it in sync with the
631    /// injected wire segment. Shared across `Clone`s (same session, one
632    /// truth) but NEVER inherited by subagents — every constructor
633    /// initializes the slot empty, same invariant as `memory_context_state`
634    /// above.
635    pending_memory_segment:
636        std::sync::Arc<std::sync::Mutex<Option<memory_context::ContextSegment>>>,
637    /// Task B4 (spec §7.4): the accepted recall retained for the CURRENT
638    /// logical request — the contribution reused verbatim by retries of the
639    /// same request (never a second provider call) plus the §10.4
640    /// explainability metadata behind `/memory why` (task B5). Shared
641    /// across `Clone`s; never inherited by subagents (fresh empty slot in
642    /// every constructor, same invariant as `pending_memory_segment`).
643    retained_recall_turn:
644        std::sync::Arc<std::sync::Mutex<Option<memory_context::RetainedRecallTurn>>>,
645    /// Writer handle backing the most recent armed one-shot ephemeral
646    /// trace context (telemetry Off + `/trace next`). Retained here — not
647    /// only inside the request's cloned context — so the session exit
648    /// epilogue (`shutdown_observability*`) can drain the armed record
649    /// even if the process exits right after the request. Replaced on
650    /// re-arm; never touched on the request path beyond one mutex store.
651    /// Shared across clones.
652    one_shot_trace_writer:
653        std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
654    /// Content-capture root, bound ONCE at construction (fix1 I2b): every
655    /// sweep — startup, status, sync and async shutdown epilogues — and the
656    /// one-shot content capture use THIS path, never a late ambient
657    /// `SYNAPS_BASE_DIR` read, so post-construction env churn (parallel
658    /// tests, profile switches) can never redirect capture I/O.
659    capture_dir: std::path::PathBuf,
660    /// Opt into the cache-diagnosis beta (`cache-diagnosis-2026-04-07`).
661    cache_diagnostics: bool,
662    /// Prompt-cache TTL strategy (5m default | 1h | hybrid). Threaded into
663    /// every request via `ApiOptions`.
664    cache_ttl: crate::core::config::CacheTtl,
665    /// One-time-per-session latch for the silent 1h-downgrade notice
666    /// (spec §3.4.1). Shared into `ApiOptions` for every request.
667    ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
668    /// Session-scoped "1h honored at least once" latch (spec §3.4.1) —
669    /// suppresses the downgrade notice on healthy Hybrid turns where the 1h
670    /// prefix is already cached. Shared into `ApiOptions` for every request.
671    saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
672    /// When this runtime's foreground request chain last had an Anthropic
673    /// request accepted (task #444 keep-warm scheduling). Fresh per clone,
674    /// shared by `share_ttl_latches`, like the TTL latches above.
675    cache_anchor: std::sync::Arc<keepwarm::CacheAnchorCell>,
676    /// Last foreground Anthropic message id (`msg_...`) — threaded into the
677    /// next request's `diagnostics.previous_message_id` when diagnostics is
678    /// on. Fresh per clone, shared by `share_ttl_latches`.
679    last_msg_id: Arc<Mutex<Option<String>>>,
680    /// Set once the API rejected cache diagnostics: off for the session.
681    /// Shared across `Clone` (subagents never re-probe a rejecting API).
682    cache_diag_rejected: Arc<std::sync::atomic::AtomicBool>,
683    session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
684    /// Extension hook bus for dispatching events to extensions.
685    hook_bus: Arc<crate::extensions::hooks::HookBus>,
686    // Held to keep the reaper task alive for the Runtime's lifetime; never read directly.
687    #[allow(dead_code)]
688    reaper_handle: Option<tokio::task::JoinHandle<()>>,
689    #[allow(dead_code)]
690    reaper_cancel: Option<tokio_util::sync::CancellationToken>,
691    /// How provider credentials are resolved: `Local` (read/refresh auth.json —
692    /// the default) or `Remote` (fetch short-lived access tokens from a broker).
693    /// Set from config in `apply_config`. See task #157.
694    credential_source: crate::auth::CredentialSource,
695    /// In-memory cache of broker-fetched access tokens (Remote source only).
696    /// Cheap to clone (Arc inside). Never persisted to disk.
697    token_cache: crate::auth::TokenCache,
698    /// Exact operator-trusted worker identities loaded from `favorite_models`.
699    /// These seed the session policy and are replayed when a manifestless
700    /// foreground model change replaces that policy snapshot.
701    trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
702    /// Subagent fan-out limits (`subagent.*`) used whenever this runtime
703    /// installs a manifestless delegation policy (boot, new session, model
704    /// change). Changing them affects the next install, not running workers.
705    subagent_limits: crate::config::SubagentLimits,
706    /// True when this stream should expose only its session projection rather
707    /// than the legacy full tool schema. Opt-in and false by default so the
708    /// flag-off request bytes stay unchanged (Task 18).
709    progressive_tool_disclosure: bool,
710    /// `tools.deferred_loading`: Anthropic `defer_loading` + `tool_reference`
711    /// for progressive disclosure, so activations keep the cached prefix.
712    deferred_tool_loading: bool,
713    /// `tools.activation_confirm` host policy for model-initiated
714    /// `activate_tools` (auto | prompt | deny). Default `auto`.
715    activation_confirm: agent_core::config::ActivationConfirm,
716    /// Current worker handle for bounded delegation-tree accounting. `None`
717    /// for foreground roots.
718    delegation_parent: Option<String>,
719    /// Session-scoped, per-extension "Allow all" latch for extension
720    /// `Confirm` gates (`before_tool_call`). An extension id is added when
721    /// the user picks "Allow all from <ext>" in the Confirm dialog; shared by
722    /// clones and delegated workers in the same session. Never persisted.
723    session_allow_all: std::sync::Arc<crate::tools::SessionConfirmLatch>,
724    /// Shared exact MCP lease manager (Task 19). Installed at engine boot
725    /// when MCP exact mode is active; streams mint per-session capabilities
726    /// and RAII guards from it.
727    mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
728    /// Durable shared session-scope guard (Task 19 review): terminates this
729    /// runtime session's MCP leases only when the LAST owner (runtime clone
730    /// or in-flight stream) drops — never per provider turn.
731    mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
732    /// Shared exact EXTENSION lease manager (Task 20). Installed at engine
733    /// boot under progressive disclosure; streams mint per-session
734    /// capabilities and hold the durable scope below.
735    extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
736    /// Durable shared session-scope guard for extension leases (Task 20):
737    /// terminates this runtime session's extension leases only when the
738    /// LAST owner (runtime clone or in-flight stream) drops.
739    extension_session_scope:
740        Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
741    /// Per-turn budget (Task 23, spec §8.1). Resolved from role + typed
742    /// config at boot; every stream turn is metered against it.
743    turn_budget: crate::runtime::budget::TurnBudget,
744    /// Set while an event-started turn's stream starts (like the event
745    /// budget in `turn_budget`): the owner's steer moves that stream onto
746    /// the session budget. `None` for every other stream.
747    owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
748    /// Private runtime-scoped tool-session identity (Task 16). Scopes the
749    /// per-stream `SessionToolSet` the execution gate authorizes against.
750    /// Minted fresh per constructed `Runtime` — two independently
751    /// constructed runtimes can never share session grants — and shared by
752    /// `Clone` because clones share the same live tool registry (the
753    /// existing shared-session behavior). Never persisted; unrelated to
754    /// saved session IDs.
755    host_tool_session: crate::tools::activation::SessionId,
756    /// Frozen system-prompt catalog of activatable tools (progressive
757    /// disclosure): rendered once per session on first use, then reused
758    /// byte-for-byte so activations and late registrations never change the
759    /// cached prompt prefix. Outer `None` = not rendered yet.
760    tool_catalog_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
761    /// The `SessionToolSet` retained for `host_tool_session` across stream
762    /// turns (exact activations are session-scoped). Minted empty with the
763    /// runtime and shared by `Clone`, exactly like `host_tool_session`.
764    retained_tool_set: crate::tools::activation::RetainedSessionToolSet,
765    /// Activations carried over from this conversation's previous runtime
766    /// (daemon reload, unpark), restored at the first progressive turn
767    /// start. Shared by `Clone` like `retained_tool_set`.
768    pending_tool_restore: crate::tools::activation::PendingToolRestore,
769    /// Conversation/session identity this runtime serves. Keys the
770    /// `on_session_start` hook injection (Phase 2 keys everything).
771    /// `None` = unkeyed (workers, tests) — reads no injection.
772    session_id: Option<String>,
773    /// Per-session working directory (Phase 2 daemon mode). `None` = process
774    /// cwd — every in-process host leaves it `None`, so `ToolCapabilities.cwd`
775    /// stays `None` exactly as before. The daemon sets it per session.
776    cwd: Option<PathBuf>,
777    /// Per-session environment snapshot (session-identity T1). `None` =
778    /// inherit process env (in-process hosts). Daemon sessions carry the
779    /// creator client's env; tools apply `env_clear().envs()` when `Some`.
780    env: Option<crate::session::types::SessionEnv>,
781    /// Names of env vars stripped as secrets (T5).
782    env_stripped: Vec<String>,
783    /// Shared per-session "already warned" set for env-stripped notices (T5).
784    /// Cloned into every `ToolCapabilities` so the dedup survives across
785    /// tool calls within the same session.
786    env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
787}
788
789/// Mint a fresh runtime-scoped tool-session identity. Process id + UUIDv4
790/// keeps it unique across runtimes and restarts; the parse cannot fail on
791/// this generated shape.
792fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
793    crate::tools::activation::SessionId::parse(&format!(
794        "runtime-{}-{}",
795        std::process::id(),
796        uuid::Uuid::new_v4()
797    ))
798    .expect("generated runtime session id is always valid")
799}
800
801/// Fresh Off/no-lease memory-context state for a newly constructed runtime
802/// (task A5). CRITICAL INVARIANT: every `Runtime` constructor calls this,
803/// and subagent spawn paths build a brand-new `Runtime::new()` before
804/// `apply_subagent_runtime_policy` runs — so subagents always start with no
805/// memory lease. Memory-context state must never be copied from a parent
806/// runtime into a freshly constructed one.
807fn fresh_memory_context_state(
808) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
809    let session = memory_context::SessionId::parse(&format!(
810        "memctx-{}-{}",
811        std::process::id(),
812        uuid::Uuid::new_v4()
813    ))
814    .expect("generated memory session id is always valid");
815    std::sync::Arc::new(std::sync::Mutex::new(
816        memory_context::SessionMemoryState::new(session),
817    ))
818}
819
820/// Host-derived project isolation identity (spec §5.2) recorded on
821/// command-granted memory leases: the stable scope discovered from the current
822/// working directory (or `SYNAPS_PROJECT_ROOT`).
823fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
824    let cwd = std::env::current_dir()
825        .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
826    agent_core::memory::store::ProjectScope::discover(&cwd)
827        .map_err(|error| format!("cannot resolve project scope: {error}"))
828}
829
830fn memory_project_id() -> memory_context::ProjectId {
831    match memory_project_scope() {
832        Ok(scope) => memory_context::ProjectId::parse(scope.key())
833            .expect("host project scope key is always a valid identifier"),
834        Err(_) => memory_context::ProjectId::parse("project-unresolved")
835            .expect("static fallback project id is valid"),
836    }
837}
838
839/// The spec-canonical continuous-memory provider identity (spec §17.3,
840/// Axel memory-manager). FALLBACK ONLY as of task A6: it is recorded on
841/// command-granted leases exclusively when NO extension subsystem is wired
842/// into the runtime (legacy/flag-off construction — see
843/// [`Runtime::resolve_memory_provider`]). When the extension runtime is
844/// installed, enable-time validation resolves the exact declared provider
845/// from the loaded catalog instead, failing closed on zero or ambiguous
846/// matches.
847fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
848    static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
849    WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
850}
851
852struct ExtensionCaptureProvider {
853    manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
854    session: crate::tools::activation::SessionId,
855    plugin: String,
856    digest: crate::tools::catalog::SchemaDigest,
857    handle: tokio::runtime::Handle,
858}
859
860impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
861    fn capture(
862        &self,
863        capture: chat_capture::ChatTurnCapture,
864    ) -> std::result::Result<(), capture_worker::CaptureFailure> {
865        let params = memory_context::capture_request_wire(&capture);
866        self.handle
867            .block_on(
868                crate::extensions::lease::ExtensionLeaseCapability::new(
869                    self.session.clone(),
870                    self.manager.clone(),
871                )
872                .call_exact(
873                    &self.plugin,
874                    memory_context::MEMORY_CAPTURE_TOOL_NAME,
875                    &self.digest,
876                    params,
877                ),
878            )
879            .map(|_| ())
880            .map_err(|_| capture_worker::CaptureFailure {
881                code: "provider_call_failed",
882            })
883    }
884
885    fn contains_capture(
886        &self,
887        capture_id: &[u8; 32],
888    ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
889    {
890        let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
891        let response = self
892            .handle
893            .block_on(
894                crate::extensions::lease::ExtensionLeaseCapability::new(
895                    self.session.clone(),
896                    self.manager.clone(),
897                )
898                .call_exact(
899                    &self.plugin,
900                    memory_context::MEMORY_CAPTURE_TOOL_NAME,
901                    &self.digest,
902                    memory_context::capture_query_wire(&capture_id),
903                ),
904            )
905            .map_err(|_| capture_worker::CaptureFailure {
906                code: "provider_query_failed",
907            })?;
908        Ok(
909            if response
910                .get("committed")
911                .and_then(serde_json::Value::as_bool)
912                == Some(true)
913            {
914                capture_worker::CaptureCommitState::Committed
915            } else {
916                capture_worker::CaptureCommitState::Absent
917            },
918        )
919    }
920
921    fn capture_summary(
922        &self,
923        capture: chat_capture::ConversationSummaryCapture,
924    ) -> std::result::Result<(), capture_worker::CaptureFailure> {
925        let params = memory_context::summary_capture_request_wire(&capture);
926        self.handle
927            .block_on(
928                crate::extensions::lease::ExtensionLeaseCapability::new(
929                    self.session.clone(),
930                    self.manager.clone(),
931                )
932                .call_exact(
933                    &self.plugin,
934                    memory_context::MEMORY_CAPTURE_TOOL_NAME,
935                    &self.digest,
936                    params,
937                ),
938            )
939            .map(|_| ())
940            .map_err(|_| capture_worker::CaptureFailure {
941                code: "provider_call_failed",
942            })
943    }
944}
945
946fn terminal_capture_history(
947    lease: &memory_context::MemoryContextLease,
948    messages: &[crate::SharedMessage],
949    started_at: std::time::SystemTime,
950) -> chat_capture::TerminalTurnHistory {
951    use agent_core::core::disclosure::DisclosureClass;
952    let mut items = Vec::new();
953    for message in messages {
954        let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
955            continue;
956        };
957        let class = match role {
958            "user" => chat_capture::CaptureContentClass::UserMessage,
959            "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
960            _ => continue,
961        };
962        let text = message
963            .get("content")
964            .and_then(serde_json::Value::as_str)
965            .map(str::to_owned)
966            .unwrap_or_else(|| {
967                message
968                    .get("content")
969                    .map(ToString::to_string)
970                    .unwrap_or_default()
971            });
972        items.push(chat_capture::CanonicalCaptureItem {
973            project_id: lease.project_id.clone(),
974            class,
975            disclosure: DisclosureClass::ModelVisible,
976            sensitivity: chat_capture::Sensitivity::Normal,
977            text,
978            tool_name: None,
979        });
980    }
981    chat_capture::TerminalTurnHistory {
982        project_id: lease.project_id.clone(),
983        session_id: lease.session_id.clone(),
984        turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
985            .expect("generated turn id"),
986        turn_ordinal: 0,
987        started_at,
988        completed_at: std::time::SystemTime::now(),
989        outcome: agent_core::TurnOutcome::Completed,
990        items,
991        compaction: None,
992    }
993}
994
995fn memory_provider_id() -> memory_context::ContextProviderId {
996    memory_context::ContextProviderId::parse("axel-memory")
997        .expect("static provider id is always valid")
998}
999
1000fn terminal_capture_start(messages: &[crate::SharedMessage]) -> Option<crate::SharedMessage> {
1001    messages
1002        .iter()
1003        .rfind(|message| {
1004            message["role"] == "user"
1005                && (message["content"].is_string()
1006                    || message["content"].as_array().is_some_and(|blocks| {
1007                        blocks.iter().any(|block| block["type"] != "tool_result")
1008                    }))
1009        })
1010        .cloned()
1011}
1012
1013fn terminal_capture_messages<'a>(
1014    messages: &'a [crate::SharedMessage],
1015    start: &crate::SharedMessage,
1016) -> Option<&'a [crate::SharedMessage]> {
1017    // Identity, not an index (recall may prepend) or text equality (old turns
1018    // can have identical prompts). If rollover removed the source, fail closed
1019    // rather than recapturing its summary or unrelated earlier history.
1020    let index = messages
1021        .iter()
1022        .position(|message| Arc::ptr_eq(message, start))?;
1023    Some(&messages[index..])
1024}
1025
1026/// The stream publishes history only after a valid terminal completion. Consume
1027/// it once, never capture the pre-inference prompt or reselect a changed lease.
1028fn dispatch_completed_terminal_capture(
1029    completed: bool,
1030    final_history: &std::sync::Mutex<Option<Vec<crate::SharedMessage>>>,
1031    state: &std::sync::Mutex<memory_context::SessionMemoryState>,
1032    selected: Option<(
1033        memory_context::MemoryContextLease,
1034        Arc<dyn capture_worker::CaptureProvider>,
1035    )>,
1036    started_at: std::time::SystemTime,
1037    turn_start: Option<&crate::SharedMessage>,
1038) -> bool {
1039    let history = final_history
1040        .lock()
1041        .unwrap_or_else(std::sync::PoisonError::into_inner)
1042        .take();
1043    let (Some(messages), Some((lease, provider))) = (history, selected) else {
1044        return false;
1045    };
1046    if !completed {
1047        return false;
1048    }
1049    let Some(messages) = turn_start.and_then(|start| terminal_capture_messages(&messages, start))
1050    else {
1051        return false;
1052    };
1053    let state = state
1054        .lock()
1055        .unwrap_or_else(std::sync::PoisonError::into_inner);
1056    if state
1057        .capture_lease_at(std::time::SystemTime::now())
1058        .as_ref()
1059        != Some(&lease)
1060    {
1061        return false;
1062    }
1063    memory_capture_worker()
1064        .submit_terminal(
1065            &lease,
1066            terminal_capture_history(&lease, messages, started_at),
1067            memory_context::RetentionClass::Standard,
1068            provider,
1069        )
1070        .unwrap_or(false)
1071}
1072
1073/// Idle timeout for the runtime HTTP client: how long a request may go
1074/// without receiving *any* bytes (headers or body chunks) before it is
1075/// killed. Resets on every received chunk, so healthy long-running streams
1076/// are never interrupted.
1077const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
1078
1079/// Build the runtime's shared HTTP client.
1080///
1081/// Timeout design (incident: session 20260714-025948-3dab):
1082/// * `connect_timeout(10s)` — connection-establishment ceiling.
1083/// * `read_timeout` — **idle** detector; resets after each successful read
1084///   (reqwest `ReadTimeoutBody`). A hung request surfaces in seconds
1085///   instead of minutes, while an actively-streaming turn can run
1086///   indefinitely.
1087/// * Deliberately **no** total `.timeout(…)`: that was a wall-clock
1088///   deadline that kept ticking while bytes flowed, killing any healthy
1089///   stream longer than 300s and taking 300s to notice a dead connection.
1090///   Turn-level lifecycle is owned by cancellation tokens, not the client.
1091fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
1092    Client::builder()
1093        .tls_built_in_webpki_certs(true)
1094        .tls_built_in_native_certs(true)
1095        .connect_timeout(Duration::from_secs(10))
1096        .read_timeout(read_timeout)
1097        .build()
1098}
1099
1100/// The process-global HTTP client for `EngineHost` (same builder as
1101/// `Runtime::new`).
1102/// The host's HTTP client builder (also used by the attach-TUI client, A4).
1103pub fn build_host_http_client() -> Result<Client> {
1104    build_http_client(HTTP_READ_TIMEOUT)
1105        .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
1106}
1107
1108/// Internal: every field of `Runtime` in one place so `new()`,
1109/// `new_headless()`, `foreground_runtime()` and `worker_runtime()` cannot
1110/// drift. `Runtime::new()` is: build fresh parts → `from_parts`.
1111pub(crate) struct RuntimeParts {
1112    pub host: crate::host::HostParts,
1113    pub session_manager: Arc<crate::tools::shell::SessionManager>,
1114    pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
1115}
1116
1117impl RuntimeParts {
1118    /// Fresh shell session manager + idle reaper (needs a tokio runtime).
1119    pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
1120        let session_manager = fresh_session_manager();
1121        let cancel = CancellationToken::new();
1122        let handle =
1123            crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
1124        Self {
1125            host,
1126            session_manager,
1127            reaper: Some((handle, cancel)),
1128        }
1129    }
1130
1131    /// Fresh shell session manager, no reaper (offline / headless).
1132    #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
1133    pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
1134        Self {
1135            host,
1136            session_manager: fresh_session_manager(),
1137            reaper: None,
1138        }
1139    }
1140}
1141
1142fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
1143    let config = crate::tools::shell::ShellConfig::default();
1144    crate::tools::shell::SessionManager::new(config)
1145}
1146
1147/// Fresh, process-local host parts — exactly what `Runtime::new()` used to
1148/// construct inline: new client, new registry, new hook bus, Local creds,
1149/// new token cache, no lease managers.
1150fn fresh_host_parts() -> Result<crate::host::HostParts> {
1151    let client = build_host_http_client()?;
1152    // Operational retention (Task 12): physically remove expired
1153    // content-capture bundles at session startup — bounded, fail-soft,
1154    // confined to the private capture dir. The root resolved here is
1155    // the SAME value bound into `capture_dir` (fix1 I2b).
1156    let capture_dir = trace::default_capture_dir();
1157    let _ = trace::sweep_expired_captures(&capture_dir);
1158    Ok(crate::host::HostParts {
1159        client,
1160        tools: Arc::new(RwLock::new(ToolRegistry::new())),
1161        hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1162        credential_source: crate::auth::CredentialSource::Local,
1163        token_cache: crate::auth::TokenCache::new(),
1164        mcp_runtime: None,
1165        extension_runtime: None,
1166        capture_dir,
1167        progressive_tool_disclosure: false,
1168    })
1169}
1170
1171/// Preserve compatibility with favorite IDs written before Anthropic used its
1172/// runtime-qualified provider name. Authorization always stores the canonical
1173/// exact identity; unrelated bare values remain invalid and are ignored.
1174fn canonical_trusted_worker_model(model: &str) -> String {
1175    let model = model.trim();
1176    if let Some(id) = model.strip_prefix("claude/") {
1177        format!("anthropic/{id}")
1178    } else if model.starts_with("claude-") {
1179        format!("anthropic/{model}")
1180    } else {
1181        model.to_owned()
1182    }
1183}
1184
1185/// The owning runtime stops its shell-session reaper when it goes away
1186/// (clones carry `None`). Without this every dropped `Runtime` — a parked
1187/// session, a finished worker — left a 30 s ticker holding the manager.
1188impl Drop for Runtime {
1189    fn drop(&mut self) {
1190        if let Some(c) = &self.reaper_cancel {
1191            c.cancel();
1192        }
1193    }
1194}
1195
1196impl Runtime {
1197    pub async fn new() -> Result<Self> {
1198        // UNCHANGED semantics: fresh everything (tests, `synaps agent`).
1199        Ok(Self::from_parts(RuntimeParts::with_reaper(
1200            fresh_host_parts()?,
1201        )))
1202    }
1203
1204    /// The single struct-literal site for `Runtime`. Host-owned parts come
1205    /// from `parts.host`; everything else is fresh.
1206    pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
1207        // Runtime construction is credential-blind. Credentials are acquired
1208        // lazily through the broker abstraction after configuration is applied;
1209        // this layer never opens auth.json or consults a secret environment var.
1210        let (auth_token, auth_type, refresh_token, token_expires) =
1211            (String::new(), "oauth".to_string(), None, Some(0));
1212        let RuntimeParts {
1213            host,
1214            session_manager,
1215            reaper,
1216        } = parts;
1217        let (reaper_handle, reaper_cancel) = match reaper {
1218            Some((h, c)) => (Some(h), Some(c)),
1219            None => (None, None),
1220        };
1221        let mcp_runtime = host.mcp_runtime;
1222        let extension_runtime = host.extension_runtime;
1223
1224        let mut runtime = Runtime {
1225            client: host.client,
1226            anthropic_stream_client: Default::default(),
1227            auth: Arc::new(RwLock::new(AuthState {
1228                auth_token,
1229                auth_type,
1230                refresh_token,
1231                token_expires,
1232                auth_file_mtime: None,
1233                bound_credential: None,
1234            })),
1235            model: crate::models::default_model().to_string(),
1236            tools: host.tools,
1237            system_prompt: None,
1238            effective_prompt: None,
1239            prompt_generation: 0,
1240            prompt_reload_source: None,
1241            thinking_budget: 4096,
1242            named_level: None,
1243            explicit_reasoning: false,
1244            codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
1245            context_window_override: None,
1246            compaction_model: None,
1247            compaction_mode: agent_core::compaction::CompactionMode::default(),
1248            compaction_exclusions: Vec::new(),
1249            remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1250            subagent_registry: Arc::new(Mutex::new(
1251                crate::runtime::subagent::SubagentRegistry::new(),
1252            )),
1253            orchestration: None,
1254            event_queue: Arc::new(crate::events::EventQueue::new(1000)),
1255            watcher_exit_path: None,
1256            max_tool_output: 30000,
1257            bash_timeout: 30,
1258            bash_max_timeout: 300,
1259            subagent_timeout: 300,
1260            api_retries: 3,
1261            refusal_retries: 2,
1262            telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
1263            telemetry_writer: None,
1264            trace_ctx: trace::TraceContext::disabled(),
1265            trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
1266            // Off/no-lease default — subagents get a FRESH construction of
1267            // this state (task A5 invariant), never a copy of the parent's.
1268            memory_context_state: fresh_memory_context_state(),
1269            // DARK defaults: legacy memory backend, continuation off (#112).
1270            memory_backend: crate::memory_backend::MemoryBinding::legacy_current(),
1271            memory_backend_config: None,
1272            memory_backend_reconfigure_denied: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1273            continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
1274            pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
1275            history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
1276            #[cfg(any(test, feature = "testing"))]
1277            capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
1278            // Empty per construction — a held recall segment is turn-scoped
1279            // session state and is never copied into a fresh runtime.
1280            pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
1281            retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
1282            one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1283            capture_dir: host.capture_dir,
1284            cache_diagnostics: false,
1285            cache_ttl: crate::core::config::CacheTtl::default(),
1286            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1287            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1288            cache_anchor: Default::default(),
1289            last_msg_id: Arc::new(Mutex::new(None)),
1290            cache_diag_rejected: Default::default(),
1291            session_manager,
1292            hook_bus: host.hook_bus,
1293            reaper_handle,
1294            reaper_cancel,
1295            credential_source: host.credential_source,
1296            token_cache: host.token_cache,
1297            trusted_worker_models: Vec::new(),
1298            subagent_limits: crate::config::SubagentLimits::default(),
1299            progressive_tool_disclosure: host.progressive_tool_disclosure,
1300            deferred_tool_loading: true,
1301            activation_confirm: agent_core::config::ActivationConfirm::default(),
1302            delegation_parent: None,
1303            session_allow_all: std::sync::Arc::new(crate::tools::SessionConfirmLatch::new()),
1304            mcp_runtime: None,
1305            mcp_session_scope: None,
1306            extension_runtime: None,
1307            extension_session_scope: None,
1308            turn_budget: crate::runtime::budget::TurnBudget::for_role(
1309                crate::runtime::budget::TurnRole::Foreground,
1310            ),
1311            owner_steer_budget: None,
1312            host_tool_session: fresh_host_tool_session(),
1313            tool_catalog_prompt: Arc::new(std::sync::Mutex::new(None)),
1314            retained_tool_set: Default::default(),
1315            pending_tool_restore: Default::default(),
1316            session_id: None,
1317            cwd: None,
1318            env: None,
1319            env_stripped: Vec::new(),
1320            env_warned: Default::default(),
1321        };
1322        // Lease managers are installed through the same seams boot used, so
1323        // the per-runtime durable session-scope guards are minted exactly as
1324        // before (keyed by this runtime's fresh host tool session).
1325        if let Some(m) = mcp_runtime {
1326            runtime.install_mcp_runtime(m);
1327        }
1328        if let Some(m) = extension_runtime {
1329            runtime.install_extension_runtime(m);
1330        }
1331        runtime
1332    }
1333
1334
1335    /// Offline construction seam for headless test harnesses (P4).
1336    ///
1337    /// Identical to [`Runtime::new`] except:
1338    /// - auth is a stub token — no `auth.json` read, no keychain, no network
1339    /// - the idle shell-session reaper is not spawned — no tokio runtime
1340    ///   required at construction time
1341    ///
1342    /// Every accessor (`model()`, `thinking_level()`, tool registry, …) works
1343    /// normally; anything that would hit the Anthropic API fails at call time,
1344    /// which is the correct behavior for a harness that only drives the UI.
1345    #[cfg(any(test, feature = "testing"))]
1346    pub fn new_headless() -> Self {
1347        let client = build_http_client(HTTP_READ_TIMEOUT)
1348            .expect("reqwest client construction is infallible with built-in roots");
1349        let host = crate::host::HostParts {
1350            client,
1351            tools: Arc::new(RwLock::new(ToolRegistry::new())),
1352            hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1353            credential_source: crate::auth::CredentialSource::Local,
1354            token_cache: crate::auth::TokenCache::new(),
1355            mcp_runtime: None,
1356            extension_runtime: None,
1357            capture_dir: trace::default_capture_dir(),
1358            progressive_tool_disclosure: false,
1359        };
1360        let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1361        runtime.auth = Arc::new(RwLock::new(AuthState {
1362            auth_token: "test-token".to_string(),
1363            auth_type: "api_key".to_string(),
1364            refresh_token: None,
1365            token_expires: None,
1366            auth_file_mtime: None,
1367            bound_credential: None,
1368        }));
1369        runtime
1370    }
1371
1372    pub fn set_system_prompt(&mut self, prompt: String) {
1373        self.system_prompt = Some(prompt);
1374    }
1375
1376    pub fn system_prompt(&self) -> Option<&str> {
1377        self.system_prompt.as_deref()
1378    }
1379
1380    /// The system prompt that actually goes on the wire for the next
1381    /// request: the configured base plus any builtin orchestration adapter
1382    /// (see [`agent_core::prompt::builtin_orchestration_adapters`]) selected
1383    /// by the *current* model identity, so the doctrine follows mid-session
1384    /// model switches. Composition is skipped when:
1385    /// - a typed prompt manifest is active — the manifest author owns the
1386    ///   full stack;
1387    /// - the runtime exposes no subagent tools (worker runtimes) — the
1388    ///   doctrine would be unactionable noise;
1389    /// - the model identity cannot be canonicalized — fail closed to the
1390    ///   unmodified base.
1391    pub async fn effective_system_prompt(&self) -> Option<String> {
1392        let base = self.orchestrated_system_prompt().await;
1393        // A typed prompt manifest owns the full stack: nothing is appended.
1394        if self.effective_prompt.is_some() {
1395            return base;
1396        }
1397        match self.frozen_tool_catalog().await {
1398            Some(catalog) => Some(match base {
1399                Some(base) if !base.is_empty() => format!("{base}\n\n{catalog}"),
1400                _ => catalog,
1401            }),
1402            None => base,
1403        }
1404    }
1405
1406    /// The progressive-disclosure catalog of activatable tools for the
1407    /// system prompt, frozen per session (see `tool_catalog_prompt`). `None`
1408    /// when progressive disclosure is off, model-initiated activation is
1409    /// denied, the runtime cannot activate tools (workers), or nothing
1410    /// outside the session core is activatable.
1411    pub async fn frozen_tool_catalog(&self) -> Option<String> {
1412        if !self.progressive_tool_disclosure
1413            || self.activation_confirm == agent_core::config::ActivationConfirm::Deny
1414        {
1415            return None;
1416        }
1417        if let Some(frozen) = self
1418            .tool_catalog_prompt
1419            .lock()
1420            .unwrap_or_else(std::sync::PoisonError::into_inner)
1421            .clone()
1422        {
1423            return frozen;
1424        }
1425        let rendered = {
1426            let registry = self.tools.read().await;
1427            if registry.get("activate_tools").is_none() {
1428                None
1429            } else {
1430                let core_set = continuation::context_tool_set(
1431                    self.host_tool_session.clone(),
1432                    registry.catalog(),
1433                    true,
1434                    self.context_management_enabled(),
1435                );
1436                let core: std::collections::HashSet<String> = core_set
1437                    .core_ids()
1438                    .map(|id| id.as_str().to_string())
1439                    .collect();
1440                crate::tools::prompt_catalog::render(
1441                    registry.catalog(),
1442                    &core,
1443                    crate::tools::activation::is_activatable_record,
1444                )
1445            }
1446        };
1447        let mut slot = self
1448            .tool_catalog_prompt
1449            .lock()
1450            .unwrap_or_else(std::sync::PoisonError::into_inner);
1451        // First render wins (a concurrent caller may have frozen it already).
1452        slot.get_or_insert(rendered).clone()
1453    }
1454
1455    /// The configured base prompt plus the builtin orchestration doctrine
1456    /// for the current model (what `effective_system_prompt` returned before
1457    /// the tool catalog was appended).
1458    async fn orchestrated_system_prompt(&self) -> Option<String> {
1459        if self.effective_prompt.is_some() {
1460            return self.system_prompt.clone();
1461        }
1462        if self.tools.read().await.get("subagent_start").is_none() {
1463            return self.system_prompt.clone();
1464        }
1465        let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1466            return self.system_prompt.clone();
1467        };
1468        let workflow_mode = match self.reasoning_level() {
1469            agent_core::reasoning::ReasoningLevel::UltraCode => {
1470                Some(agent_core::prompt::WorkflowMode::UltraCode)
1471            }
1472            agent_core::reasoning::ReasoningLevel::Max => {
1473                Some(agent_core::prompt::WorkflowMode::Max)
1474            }
1475            agent_core::reasoning::ReasoningLevel::XHigh => {
1476                Some(agent_core::prompt::WorkflowMode::XHigh)
1477            }
1478            _ => None,
1479        };
1480        let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1481            .map(|context| context.with_workflow_mode(workflow_mode))
1482        else {
1483            return self.system_prompt.clone();
1484        };
1485        agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1486    }
1487
1488    pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1489        self.effective_prompt.as_ref()
1490    }
1491
1492    pub fn prompt_generation(&self) -> u64 {
1493        self.prompt_generation
1494    }
1495
1496    pub fn retain_prompt_reload_source(
1497        &mut self,
1498        manifest: PathBuf,
1499        context: agent_core::prompt::SelectionContext,
1500        user_module: Option<agent_core::prompt::PromptModule>,
1501        delegation_policy_digest: Option<String>,
1502    ) {
1503        self.prompt_reload_source = Some(PromptReloadSource {
1504            manifest,
1505            context,
1506            user_module,
1507            delegation_policy_digest,
1508        });
1509    }
1510
1511    /// Recompile retained manifest inputs, validate, then atomically install the candidate.
1512    pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1513        let source = self.prompt_reload_source.clone().ok_or_else(|| {
1514            agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1515        })?;
1516        let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1517            agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1518        })?;
1519        let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1520        let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1521            source.context.model(),
1522            manifest.delegation_catalog_candidates(),
1523        )
1524        .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1525        let candidate_policy_digest = manifest
1526            .delegation_policy(source.context.model().clone(), &reload_catalog)?
1527            .map(|policy| policy.digest());
1528        if candidate_policy_digest != source.delegation_policy_digest {
1529            return Err(agent_core::prompt::PromptError::Invalid(
1530                "hot reload cannot safely change delegation policy".into(),
1531            ));
1532        }
1533        let registry = manifest.registry(source.manifest.parent())?;
1534        let candidate = agent_core::prompt::compile_prompt_stack(
1535            &manifest,
1536            &registry,
1537            &source.context,
1538            source.user_module,
1539        )?;
1540        self.apply_prompt_stack(candidate)
1541    }
1542
1543    /// Atomically validate and install a compiled stack. Failed validation leaves all state intact.
1544    pub fn apply_prompt_stack(
1545        &mut self,
1546        candidate: agent_core::prompt::PromptStack,
1547    ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1548        if let Some(current) = &self.effective_prompt {
1549            current.validate_hot_reload(&candidate)?;
1550        }
1551        let composed = candidate.composed().to_owned();
1552        self.effective_prompt = Some(candidate);
1553        self.system_prompt = Some(composed);
1554        self.prompt_generation = self.prompt_generation.saturating_add(1);
1555        Ok(self.prompt_generation)
1556    }
1557
1558    pub fn prompt_inspection_json(&self) -> Option<String> {
1559        let mode = self
1560            .orchestration
1561            .as_ref()
1562            .map(|runtime| runtime.enforcement_mode())
1563            .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1564        self.effective_prompt.as_ref().and_then(|stack| {
1565            serde_json::to_string(&serde_json::json!({
1566                "generation": self.prompt_generation,
1567                "effective": stack.inspect(mode),
1568                "token_estimate": stack.composed().len().div_ceil(4),
1569            }))
1570            .ok()
1571        })
1572    }
1573
1574    /// Install the shared exact MCP lease manager (Task 19, engine boot).
1575    /// Also mints ONE durable session-scope guard for the runtime's tool
1576    /// session, shared by every clone and stream; only the LAST owner's
1577    /// drop terminates leases.
1578    pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1579        self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1580            self.host_tool_session.clone(),
1581            std::sync::Arc::clone(&manager),
1582        )));
1583        self.mcp_runtime = Some(manager);
1584    }
1585
1586    /// Set this runtime's per-turn budget (Task 23). The engine resolves
1587    /// role + typed config at boot; subagent/watcher constructions pass
1588    /// their role's budget explicitly.
1589    pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1590        self.turn_budget = budget;
1591    }
1592
1593    /// Hand the next stream an [`OwnerSteerBudget`](crate::runtime::budget::OwnerSteerBudget)
1594    /// (`None` clears it). Like [`Self::set_turn_budget`], the stream takes it
1595    /// when it starts.
1596    pub fn set_owner_steer_budget(
1597        &mut self,
1598        lift: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
1599    ) {
1600        self.owner_steer_budget = lift;
1601    }
1602
1603    /// The currently configured per-turn budget.
1604    pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1605        &self.turn_budget
1606    }
1607
1608    /// Install the shared exact EXTENSION lease manager (Task 20, engine
1609    /// boot). Also mints ONE durable session-scope guard for the runtime's
1610    /// tool session, shared by every clone and stream; only the LAST
1611    /// owner's drop terminates leases.
1612    pub fn install_extension_runtime(
1613        &mut self,
1614        manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1615    ) {
1616        // Bind the handler host scope to THIS runtime's durable tool
1617        // session: a Mixed extension's tool leases and hook/provider/user
1618        // handler leases share one key — ONE shared child per plugin.
1619        manager.bind_host_scope(self.host_tool_session.clone());
1620        self.extension_session_scope = Some(std::sync::Arc::new(
1621            crate::extensions::lease::ExtensionSessionEndGuard::new(
1622                self.host_tool_session.clone(),
1623                std::sync::Arc::clone(&manager),
1624            ),
1625        ));
1626        self.extension_runtime = Some(manager);
1627    }
1628
1629    pub fn install_orchestration(
1630        &mut self,
1631        runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1632    ) {
1633        for model in &self.trusted_worker_models {
1634            if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1635                tracing::warn!(
1636                    model = model.as_str(),
1637                    error = %error,
1638                    "failed to apply configured worker-model trust"
1639                );
1640            }
1641        }
1642        self.orchestration = Some(runtime);
1643    }
1644
1645    /// A runtime spawned for a worker shares only this session's bounded
1646    /// dispatch/tree authority. It must not replay process-global favorite
1647    /// models: doing so would turn a child construction into a fresh grant
1648    /// source. The child can dispatch only identities already authorized by
1649    /// this installed session policy.
1650    pub fn install_worker_orchestration(
1651        &mut self,
1652        runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1653    ) {
1654        self.orchestration = Some(runtime);
1655    }
1656
1657    /// The `subagent.*` limits this runtime sizes delegation policies with.
1658    pub fn subagent_limits(&self) -> crate::config::SubagentLimits {
1659        self.subagent_limits
1660    }
1661
1662    pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1663        self.delegation_parent = parent;
1664    }
1665
1666    pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1667        self.orchestration.as_ref()
1668    }
1669
1670    /// Extends the live session delegation policy with one explicitly
1671    /// user-trusted worker model (e.g. favorited mid-session in the models
1672    /// picker). Mid-session trust grants were always meant to be honored;
1673    /// the policy snapshot is not pinned against user decisions.
1674    pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1675        self.orchestration
1676            .as_ref()
1677            .ok_or_else(|| "delegation policy unavailable".to_string())?
1678            .grant_worker_model(model)
1679    }
1680
1681    /// Infallible model change. Returns the reasoning clamp applied (if any)
1682    /// when the current level is not supported by the new model; errors from
1683    /// `try_set_model` are swallowed (model left unchanged).
1684    pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1685        self.try_set_model(model).ok().flatten()
1686    }
1687
1688    /// Apply a model change while preserving orchestration lifecycle invariants.
1689    /// Returns an error instead of mutating either model or policy when active
1690    /// workers still require collection/reconciliation or when the replacement
1691    /// foreground cannot produce a trusted manifestless policy snapshot.
1692    /// On success, returns `Some(ReasoningClamp)` when an explicit reasoning
1693    /// level had to be substituted because the new model does not support it.
1694    pub fn try_set_model(
1695        &mut self,
1696        model: String,
1697    ) -> std::result::Result<Option<ReasoningClamp>, String> {
1698        // Older model pickers passed the rendered health row back here, e.g.
1699        // `✅  339ms  groq/llama-3.3-70b`. Remove that exact decoration shape,
1700        // rather than searching inside the ID: provider-qualified IDs may
1701        // legitimately contain `claude-` after their slash.
1702        let trimmed = model.trim();
1703        let cleaned = trimmed
1704            .split_once(char::is_whitespace)
1705            .and_then(|(_, rest)| {
1706                let rest = rest.trim_start();
1707                let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1708                let millis = latency.strip_suffix("ms")?;
1709                if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1710                    let candidate = candidate.trim();
1711                    if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1712                        return Some(candidate);
1713                    }
1714                }
1715                None
1716            })
1717            .unwrap_or(trimmed);
1718        // Manifestless orchestration snapshots are tied to the exact foreground
1719        // identity. Refuse the model mutation while a policy worker remains
1720        // unreconciled; replacing its registry would erase the completion-gate
1721        // remediation path, while changing only the runtime model would create a
1722        // stale authorization snapshot.
1723        if self.prompt_reload_source.is_none()
1724            && self
1725                .orchestration
1726                .as_ref()
1727                .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1728        {
1729            return Err("model change blocked: workers require collection/reconciliation".into());
1730        }
1731        let orchestration_replacement = if self.prompt_reload_source.is_none()
1732            && self.orchestration.is_some()
1733        {
1734            let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1735                .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1736            let replacement = crate::orchestration::OrchestrationRuntime::from_limits(
1737                foreground,
1738                self.subagent_limits,
1739            )
1740            .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1741            for trusted in &self.trusted_worker_models {
1742                replacement
1743                    .grant_worker_model(trusted.as_str())
1744                    .map_err(|_| {
1745                        "model change blocked: configured worker trust invalid".to_string()
1746                    })?;
1747            }
1748            Some(Arc::new(replacement))
1749        } else {
1750            None
1751        };
1752        self.model = cleaned.to_owned();
1753        // Replace the manifestless policy atomically after a safe model change so
1754        // inheritance and exact same-provider choices follow the new foreground.
1755        // Typed manifest policies are immutable and are not rewritten here.
1756        if let Some(replacement) = orchestration_replacement {
1757            self.orchestration = Some(replacement);
1758        }
1759        // Apply the new model's default reasoning level from exact capability
1760        // metadata (Codex catalog default, xAI documented default/Adaptive)
1761        // unless the user has explicitly chosen a level (explicit_reasoning is
1762        // true — set via command, config, or explicit session restore).
1763        // Derived/default levels are overwritten by the new model's default.
1764        if !self.explicit_reasoning {
1765            if let Some(level) =
1766                crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1767            {
1768                self.set_reasoning_level(level);
1769            }
1770        }
1771        // An explicit level that the new model rejects would otherwise make
1772        // every subsequent turn fail pre-network until the user runs
1773        // `/thinking`. Substitute the nearest documented level instead, keeping
1774        // the explicit provenance flag so later switches still honor the user.
1775        Ok(self.clamp_reasoning_to_model())
1776    }
1777
1778    /// If the current effective reasoning level fails validation against the
1779    /// current model, replace it with the model's documented default (or the
1780    /// first documented option that validates). The `explicit_reasoning` flag
1781    /// is preserved. Returns the substitution performed, if any; leaves state
1782    /// untouched (and returns `None`) when nothing validates.
1783    fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1784        use crate::runtime::openai::catalog::validation::{
1785            default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1786        };
1787        let model = self.model.as_str();
1788        let from = self.reasoning_level();
1789        if validate_reasoning_mutation(model, from).is_ok() {
1790            return None;
1791        }
1792        let to = default_level_for_model(model)
1793            .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1794            .or_else(|| {
1795                thinking_options_for_model(model)
1796                    .iter()
1797                    .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1798                    .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1799            })?;
1800        let explicit = self.explicit_reasoning;
1801        self.set_reasoning_level(to);
1802        self.explicit_reasoning = explicit;
1803        Some(ReasoningClamp { from, to })
1804    }
1805
1806    pub fn set_tools(&mut self, tools: ToolRegistry) {
1807        self.tools = Arc::new(RwLock::new(tools));
1808    }
1809
1810    pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1811        &self.subagent_registry
1812    }
1813
1814    pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1815        &self.event_queue
1816    }
1817
1818    /// Install a session-lifetime queue (B3 unpark: the queue outlives the
1819    /// `Runtime` so `synaps send` keeps resolving while parked). Must be
1820    /// called before the first turn — in-flight streams hold a clone.
1821    pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1822        self.event_queue = queue;
1823    }
1824
1825    /// Get a shared reference to the extension hook bus.
1826    pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1827        &self.hook_bus
1828    }
1829
1830    /// Background shell/PTY sessions owned by this runtime (`Checkpoint`
1831    /// and `Parked` close them via `shutdown_all`).
1832    pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1833        &self.session_manager
1834    }
1835
1836    /// Runtime-scoped tool-session identity used by the stream execution
1837    /// gate (Task 16). Shared by clones (which share the tool registry);
1838    /// fresh per independently constructed `Runtime`.
1839    pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1840        &self.host_tool_session
1841    }
1842
1843    /// Conversation/session identity this runtime serves (keys hook
1844    /// injection). `None` = unkeyed (workers). Does not reset consent: linked
1845    /// compaction successors remain the same conversation. Use
1846    /// [`Self::begin_conversation`] for explicit new/resume boundaries.
1847    pub fn set_session_id(&mut self, id: Option<String>) {
1848        self.session_id = id;
1849    }
1850
1851    /// Start an explicitly new or resumed conversation with fresh Confirm consent.
1852    /// Revoke the old set first, so workers still running from the previous
1853    /// conversation stop auto-approving, then replace the Arc so old
1854    /// stream/worker clones cannot grant consent to the next conversation.
1855    /// Ordinary turns and linked compaction successors must not call this.
1856    pub fn begin_conversation(&mut self, id: Option<String>) {
1857        self.session_allow_all.revoke();
1858        self.session_allow_all = std::sync::Arc::new(crate::tools::SessionConfirmLatch::new());
1859        // New conversation, new cache-diagnosis chain (cleared in place so
1860        // shared driver/ping clones see it too).
1861        *self
1862            .last_msg_id
1863            .lock()
1864            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1865        self.set_session_id(id);
1866    }
1867
1868    pub fn session_id(&self) -> Option<&str> {
1869        self.session_id.as_deref()
1870    }
1871
1872    /// Per-session working directory handed to tools via
1873    /// `ToolCapabilities.cwd`. `None` = process cwd.
1874    pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1875        self.cwd = cwd;
1876    }
1877
1878    pub fn cwd(&self) -> Option<&Path> {
1879        self.cwd.as_deref()
1880    }
1881
1882    /// Per-session environment snapshot handed to tools via
1883    /// `ToolCapabilities.env`. `None` = inherit process env.
1884    pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1885        self.env = env;
1886    }
1887
1888    pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1889        self.env_stripped = stripped;
1890    }
1891
1892    pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1893        self.env.as_ref()
1894    }
1895
1896    pub fn env_stripped(&self) -> &[String] {
1897        &self.env_stripped
1898    }
1899
1900    /// Get a shared reference to the tool registry (for MCP lazy loading).
1901    pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1902        Arc::clone(&self.tools)
1903    }
1904
1905    pub fn model(&self) -> &str {
1906        &self.model
1907    }
1908
1909    pub fn http_client(&self) -> &Client {
1910        &self.client
1911    }
1912
1913    /// Load provider capability evidence for `model` into THIS process before
1914    /// a model / reasoning mutation is validated against it. Only Meta Muse
1915    /// needs this: its exact effort set comes from a live, server-driven
1916    /// catalog held in a process-local cache, which a daemon-hosted session
1917    /// does not share with the TUI that listed the model. Uses this
1918    /// runtime's own credential source (local or remote broker); bounded,
1919    /// backed off, and never fails the caller.
1920    pub async fn prepare_model_capabilities(&self, model: &str) {
1921        if model.starts_with("muse/") {
1922            let broker = crate::auth::broker_from_source(
1923                &self.credential_source,
1924                &self.token_cache,
1925                self.client.clone(),
1926            );
1927            crate::runtime::openai::catalog::ensure_muse_catalog_for(model, &broker).await;
1928        }
1929    }
1930
1931    /// Whether this runtime projects a progressive (core-only) tool schema.
1932    pub fn progressive_tool_disclosure(&self) -> bool {
1933        self.progressive_tool_disclosure
1934    }
1935
1936    /// This conversation's exact tool activations as plain data, for the
1937    /// next runtime that serves it (daemon reload, park → unpark). The
1938    /// retained set's activations once a progressive turn ran; before that,
1939    /// whatever this runtime was itself handed and has not applied yet — so
1940    /// a reload or park before the first turn does not lose them either.
1941    pub fn carried_tool_activations(&self) -> Vec<crate::tools::activation::CarriedActivation> {
1942        let retained = self
1943            .retained_tool_set
1944            .lock()
1945            .unwrap_or_else(std::sync::PoisonError::into_inner)
1946            .clone();
1947        if let Some(shared) = retained {
1948            return shared
1949                .read()
1950                .unwrap_or_else(std::sync::PoisonError::into_inner)
1951                .carried_activations();
1952        }
1953        self.pending_tool_restore
1954            .lock()
1955            .unwrap_or_else(std::sync::PoisonError::into_inner)
1956            .clone()
1957            .unwrap_or_default()
1958    }
1959
1960    /// Hand this runtime the activations the same conversation held in its
1961    /// previous runtime. Nothing is granted here: they are re-validated and
1962    /// restored (or dropped and reported) at the first progressive turn
1963    /// start, against that moment's catalog and this runtime's
1964    /// `tools.activation_confirm` (see
1965    /// `runtime::stream::restore_pending_activations`). An empty list
1966    /// clears nothing that is already pending.
1967    pub fn restore_tool_activations(
1968        &self,
1969        carried: Vec<crate::tools::activation::CarriedActivation>,
1970    ) {
1971        if carried.is_empty() {
1972            return;
1973        }
1974        let mut pending = self
1975            .pending_tool_restore
1976            .lock()
1977            .unwrap_or_else(std::sync::PoisonError::into_inner);
1978        match pending.as_mut() {
1979            Some(list) => {
1980                for c in carried {
1981                    if !list.iter().any(|p| p.tool_id == c.tool_id) {
1982                        list.push(c);
1983                    }
1984                }
1985            }
1986            None => *pending = Some(carried),
1987        }
1988    }
1989
1990    /// `tools.activation_confirm` policy in force for this runtime.
1991    pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1992        self.activation_confirm
1993    }
1994
1995    /// Override the `tools.activation_confirm` policy (hosts/tests that do
1996    /// not go through `apply_config`).
1997    pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1998        self.activation_confirm = mode;
1999    }
2000
2001    /// Identity of the underlying connection pool: two `Client` handles that
2002    /// clone the same `Arc` share one pool. `reqwest::Client` is a single
2003    /// `Arc<ClientRef>`, so its bytes ARE the pool pointer.
2004    #[cfg(any(test, feature = "testing"))]
2005    pub fn http_client_pool_id(&self) -> usize {
2006        const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
2007        // SAFETY: size asserted above; only the bit pattern is read, no
2008        // ownership is taken (transmute_copy does not move out of `self`).
2009        unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
2010    }
2011    pub fn set_thinking_budget(&mut self, budget: u32) {
2012        self.thinking_budget = budget;
2013        // Sync named_level from budget so the two fields stay consistent.
2014        self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
2015            budget,
2016        ));
2017        // Config/restore path — not an explicit user choice.
2018        self.explicit_reasoning = false;
2019    }
2020
2021    /// Set the named reasoning level (config/restore path). Updates
2022    /// `thinking_budget` from the level's canonical budget when one exists;
2023    /// for Max/Ultra (no numeric budget), leaves `thinking_budget` unchanged.
2024    /// Marks `explicit_reasoning = false` — use `set_reasoning_level_explicit`
2025    /// for user commands and settings.
2026    pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
2027        self.named_level = Some(level);
2028        if let Some(budget) = level.to_legacy_budget() {
2029            self.thinking_budget = budget;
2030        }
2031        // Max/Ultra: do NOT overwrite thinking_budget with u32::MAX.
2032        self.explicit_reasoning = false;
2033    }
2034
2035    /// Set the named reasoning level as an **explicit user choice** (slash
2036    /// commands, settings panel). Identical to `set_reasoning_level` but sets
2037    /// `explicit_reasoning = true` so that a subsequent `set_model` call does
2038    /// not overwrite the level with the new model's default.
2039    pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
2040        self.set_reasoning_level(level);
2041        self.explicit_reasoning = true;
2042    }
2043
2044    /// Restore a saved session's `thinking_level` string as an explicit user
2045    /// choice, then clamp it against the current model. Call **after**
2046    /// `set_model(session.model)`: re-applying the saved level would otherwise
2047    /// undo the clamp `set_model` performed (old grok+xhigh session files).
2048    /// Returns the clamp applied, if any, so callers can surface it.
2049    pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
2050        if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
2051            self.set_reasoning_level_explicit(level);
2052        } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
2053            self.set_thinking_budget_explicit(budget);
2054        } else {
2055            return None;
2056        }
2057        self.clamp_reasoning_to_model()
2058    }
2059
2060    /// Set a custom numeric thinking budget as an **explicit user choice**
2061    /// (e.g. `/thinking 8192`). Retains the exact budget in `thinking_budget`
2062    /// while syncing `named_level` to the nearest named level for display.
2063    /// Sets `explicit_reasoning = true`.
2064    pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
2065        self.thinking_budget = budget;
2066        self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
2067            budget,
2068        ));
2069        self.explicit_reasoning = true;
2070    }
2071
2072    /// Expose the `explicit_reasoning` provenance flag (test/introspection).
2073    pub fn is_reasoning_explicit(&self) -> bool {
2074        self.explicit_reasoning
2075    }
2076
2077    /// Raw thinking budget value (for testing and legacy request building).
2078    pub fn thinking_budget_raw(&self) -> u32 {
2079        self.thinking_budget
2080    }
2081
2082    /// Validate `level` against the current model's capability metadata via
2083    /// the shared per-provider validator (cache then exact static tables),
2084    /// then apply it. Returns `Err(user-facing message)` and leaves runtime
2085    /// state unchanged if the level is unsupported.
2086    pub fn set_reasoning_level_checked(
2087        &mut self,
2088        level: agent_core::reasoning::ReasoningLevel,
2089    ) -> std::result::Result<(), String> {
2090        crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2091            &self.model,
2092            level,
2093        )?;
2094        if self.model.starts_with("anthropic/")
2095            && level == agent_core::reasoning::ReasoningLevel::UltraCode
2096        {
2097            if self.codex_request_role()
2098                != crate::runtime::openai::catalog::ExecutionRole::Foreground
2099            {
2100                return Err("ultracode_requires_foreground".into());
2101            }
2102            if self.effective_prompt.is_some() {
2103                return Err("typed_prompt_manifest_blocks_required_doctrine".into());
2104            }
2105            if self.orchestration.is_none() {
2106                return Err("ultracode_requires_orchestration".into());
2107            }
2108            let tools = self.tools.try_read().map_err(|_| {
2109                "ultracode prerequisite state is busy; refusing mutation".to_string()
2110            })?;
2111            let required = ["subagent_start", "subagent_status", "subagent_collect"];
2112            if !required.iter().all(|name| {
2113                tools
2114                    .get(name)
2115                    .is_some_and(|tool| tool.extension_id().is_none())
2116            }) {
2117                return Err("ultracode_requires_lifecycle_tools".into());
2118            }
2119        }
2120        self.set_reasoning_level_explicit(level);
2121        Ok(())
2122    }
2123
2124    /// Validate then apply a custom numeric thinking budget (`/thinking <N>`).
2125    /// The budget's derived `ReasoningLevel` runs through the same exact-model
2126    /// mutation validator as named levels (Anthropic fixed-budget models pass:
2127    /// derived levels never exceed XHigh and thinking-capable descriptors
2128    /// accept them). On `Ok` the exact budget is retained — never downgraded
2129    /// to a named-level canonical budget. On `Err` state is unchanged.
2130    pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
2131        let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
2132        crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2133            &self.model,
2134            level,
2135        )?;
2136        self.set_thinking_budget_explicit(budget);
2137        Ok(())
2138    }
2139
2140    pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
2141        self.named_level.unwrap_or_else(|| {
2142            agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
2143        })
2144    }
2145
2146    pub(crate) fn set_codex_request_role(
2147        &mut self,
2148        role: crate::runtime::openai::catalog::CodexRequestRole,
2149    ) {
2150        self.codex_request_role = role;
2151    }
2152
2153    pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
2154        self.codex_request_role
2155    }
2156
2157    pub(crate) fn codex_delegation_plan(
2158        model: &str,
2159        level: agent_core::reasoning::ReasoningLevel,
2160        role: crate::runtime::openai::catalog::CodexRequestRole,
2161    ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
2162        use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
2163        if role != CodexRequestRole::Foreground
2164            || level != agent_core::reasoning::ReasoningLevel::Ultra
2165            || !model.starts_with("openai-codex/")
2166        {
2167            return None;
2168        }
2169        plan_codex_execution(model, level, role, None).ok()
2170    }
2171
2172    async fn authorized_anthropic_plan(
2173        &self,
2174    ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
2175        if !self.model.starts_with("anthropic/") {
2176            return Ok(None);
2177        }
2178        let tools = self.tools.read().await;
2179        let builtin = |name: &str| {
2180            tools
2181                .get(name)
2182                .is_some_and(|tool| tool.extension_id().is_none())
2183        };
2184        let readiness = self
2185            .orchestration
2186            .as_ref()
2187            .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
2188        let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
2189            orchestration_policy: self.orchestration.is_some(),
2190            foreground_worker_authorized: readiness.is_some(),
2191            concurrent_limit: readiness.map_or(0, |limits| limits.0),
2192            total_limit: readiness.map_or(0, |limits| limits.1),
2193            lifecycle_start: builtin("subagent_start"),
2194            lifecycle_status: builtin("subagent_status"),
2195            lifecycle_steer: builtin("subagent_steer"),
2196            lifecycle_collect: builtin("subagent_collect"),
2197            lifecycle_resume: builtin("subagent_resume"),
2198        };
2199        crate::runtime::openai::catalog::plan_anthropic_execution(
2200            &self.model,
2201            self.reasoning_level(),
2202            self.codex_request_role(),
2203            prerequisites,
2204            crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
2205                match entry.reasoning {
2206                    crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2207                        adaptive,
2208                    } => Some(adaptive),
2209                    crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2210                    _ => None,
2211                }
2212            }),
2213        )
2214        .map(Some)
2215        .map_err(|error| RuntimeError::Config(error.to_string()))
2216    }
2217
2218    /// Validate exact-model reasoning and Ultra orchestration prerequisites
2219    /// before refresh, broker access, or provider network work.
2220    async fn validate_request_preflight(&self) -> Result<()> {
2221        self.validate_request_preflight_for(&self.model, self.codex_request_role)
2222            .await
2223    }
2224
2225    /// Session-driver preflight: same validation as request preflight.
2226    /// merge(112): added for session_driver.rs; thin delegation.
2227    pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
2228        self.validate_request_preflight().await
2229    }
2230
2231    async fn validate_request_preflight_for(
2232        &self,
2233        model: &str,
2234        role: crate::runtime::openai::catalog::CodexRequestRole,
2235    ) -> Result<()> {
2236        // A denied memory-backend reconfiguration poisons the runtime: the
2237        // live binding no longer matches config, so no request may be sent
2238        // until restart. (#112; upstream `validate_request_preflight_for`.)
2239        if self
2240            .memory_backend_reconfigure_denied
2241            .load(std::sync::atomic::Ordering::SeqCst)
2242        {
2243            return Err(RuntimeError::Config(
2244                "memory backend cannot be reconfigured at runtime (including live legacy extension processes); restart required; request denied".into(),
2245            ));
2246        }
2247        let level = self.reasoning_level();
2248        if model.starts_with("anthropic/")
2249            && level == agent_core::reasoning::ReasoningLevel::UltraCode
2250            && self.effective_prompt.is_some()
2251        {
2252            return Err(RuntimeError::Config(
2253                "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
2254                    .into(),
2255            ));
2256        }
2257        if model.starts_with("anthropic/")
2258            && matches!(
2259                level,
2260                agent_core::reasoning::ReasoningLevel::Max
2261                    | agent_core::reasoning::ReasoningLevel::UltraCode
2262                    | agent_core::reasoning::ReasoningLevel::Ultra
2263            )
2264        {
2265            let tools = self.tools.read().await;
2266            let builtin = |name: &str| {
2267                tools
2268                    .get(name)
2269                    .is_some_and(|tool| tool.extension_id().is_none())
2270            };
2271            let readiness = self
2272                .orchestration
2273                .as_ref()
2274                .and_then(|runtime| runtime.ultracode_readiness(model).ok());
2275            let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
2276                orchestration_policy: self.orchestration.is_some(),
2277                foreground_worker_authorized: readiness.is_some(),
2278                concurrent_limit: readiness.map_or(0, |limits| limits.0),
2279                total_limit: readiness.map_or(0, |limits| limits.1),
2280                lifecycle_start: builtin("subagent_start"),
2281                lifecycle_status: builtin("subagent_status"),
2282                lifecycle_steer: builtin("subagent_steer"),
2283                lifecycle_collect: builtin("subagent_collect"),
2284                lifecycle_resume: builtin("subagent_resume"),
2285            };
2286            drop(tools);
2287            let result = crate::runtime::openai::catalog::plan_anthropic_execution(
2288                model,
2289                level,
2290                role,
2291                prerequisites,
2292                crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
2293                    match entry.reasoning {
2294                        crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2295                            adaptive,
2296                        } => Some(adaptive),
2297                        crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2298                        _ => None,
2299                    }
2300                }),
2301            );
2302            match result {
2303                Ok(plan) => {
2304                    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);
2305                    return Ok(());
2306                }
2307                Err(error) => {
2308                    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);
2309                    return Err(RuntimeError::Config(error.to_string()));
2310                }
2311            }
2312        }
2313        if model.starts_with("openai-codex/") {
2314            let plan =
2315                crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
2316                    .map_err(|error| {
2317                        tracing::debug!(
2318                            event = "codex_mode_plan",
2319                            qualified_model = %model,
2320                            requested_level = %level,
2321                            runtime_role = role.as_str(),
2322                            decision = "deny",
2323                            deny_code = error.code().as_str(),
2324                            network_attempted = false,
2325                            "Codex request preflight denied"
2326                        );
2327                        RuntimeError::Config(error.to_string())
2328                    })?;
2329
2330            if plan.automatic_delegation() {
2331                if self.orchestration.is_none() {
2332                    tracing::debug!(
2333                        event = "codex_mode_plan",
2334                        qualified_model = %model,
2335                        requested_level = %level,
2336                        runtime_role = role.as_str(),
2337                        decision = "deny",
2338                        deny_code = "ultra_requires_orchestration",
2339                        network_attempted = false,
2340                        "Codex request preflight denied"
2341                    );
2342                    return Err(RuntimeError::Config(
2343                        "Ultra requires an installed orchestration policy".to_string(),
2344                    ));
2345                }
2346
2347                let tools = self.tools.read().await;
2348                let required = ["subagent_start", "subagent_status", "subagent_collect"];
2349                let tools_ready = required.iter().all(|name| {
2350                    tools
2351                        .get(name)
2352                        .is_some_and(|tool| tool.extension_id().is_none())
2353                });
2354                if !tools_ready {
2355                    tracing::debug!(
2356                        event = "codex_mode_plan",
2357                        qualified_model = %model,
2358                        requested_level = %level,
2359                        runtime_role = role.as_str(),
2360                        decision = "deny",
2361                        deny_code = "ultra_requires_subagent_tools",
2362                        network_attempted = false,
2363                        "Codex request preflight denied"
2364                    );
2365                    return Err(RuntimeError::Config(
2366                        "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
2367                            .to_string(),
2368                    ));
2369                }
2370            }
2371            return Ok(());
2372        }
2373
2374        crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
2375            .map_err(|error| {
2376                tracing::debug!(
2377                    event = "codex_mode_plan",
2378                    qualified_model = %model,
2379                    requested_level = %level,
2380                    runtime_role = role.as_str(),
2381                    decision = "deny",
2382                    deny_code = "unsupported_reasoning_level",
2383                    network_attempted = false,
2384                    "request preflight denied"
2385                );
2386                RuntimeError::Config(error)
2387            })
2388    }
2389
2390    pub fn set_compaction_model(&mut self, model: Option<String>) {
2391        self.compaction_model = model;
2392    }
2393
2394    /// Set where compaction summarization runs (spec §9.4).
2395    pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
2396        self.compaction_mode = mode;
2397    }
2398
2399    /// Set the content classes withheld from remote compaction disclosure.
2400    pub fn set_compaction_exclusions(
2401        &mut self,
2402        exclude: Vec<agent_core::compaction::ContentClass>,
2403    ) {
2404        self.compaction_exclusions = exclude;
2405    }
2406
2407    /// Number of times the remote-summarization transport seam was entered
2408    /// (CP-12 M3). Local-only compaction must leave this at zero.
2409    pub fn remote_summarization_attempts(&self) -> u64 {
2410        self.remote_summarization_attempts
2411            .load(std::sync::atomic::Ordering::SeqCst)
2412    }
2413
2414    /// The session's compaction disclosure policy (spec §9.4) — consumed by
2415    /// `runtime::compaction` for both the preview and the dispatch path.
2416    pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
2417        crate::runtime::compaction::DisclosurePolicy {
2418            mode: self.compaction_mode,
2419            exclude: self.compaction_exclusions.clone(),
2420        }
2421    }
2422
2423    pub fn set_context_window(&mut self, window: Option<u64>) {
2424        self.context_window_override = window;
2425    }
2426
2427    /// Effective context window for the current model — user override if set,
2428    /// otherwise the model's native window from `models::context_window_for_model`.
2429    pub fn compaction_model(&self) -> &str {
2430        self.compaction_model
2431            .as_deref()
2432            .unwrap_or("claude-sonnet-4-6")
2433    }
2434
2435    pub fn context_window(&self) -> u64 {
2436        self.context_window_override
2437            .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
2438    }
2439
2440    /// Task 29 (spec §9.1): the ONE request-aware context assessment every
2441    /// frontend consumes on its compaction trigger path. Reads the segments
2442    /// the next request will actually carry — the effective system prompt,
2443    /// the exposed tool-schema set, the supplied history — and the runtime's
2444    /// configured reserves (thinking budget, tool-result cap, model output
2445    /// reserve, provider window with the documented safety margin).
2446    ///
2447    /// Skill bodies that reach the request do so through the system prompt
2448    /// or history today, so they are already accounted there; the skill lane
2449    /// is fed once loaders hand the engine distinct segments. The memory
2450    /// lane is live as of task A7: a validated recall segment held for the
2451    /// current turn is charged to `ContextBreakdown::memory_tokens` (spec
2452    /// §10.3 — memory competes for budget headroom only, never reserves).
2453    pub async fn assess_context(
2454        &self,
2455        messages: &[crate::SharedMessage],
2456    ) -> context::ContextAssessment {
2457        let system = self.effective_system_prompt().await;
2458        let schema = self.tools.read().await.tools_schema();
2459        // Task A7 (spec §10.1/§10.3): the T29 memory lane is fed by the
2460        // validated recall segment held for this turn, if any. No held
2461        // segment ⇒ the lane stays empty — byte-identical to the pre-A7
2462        // assessment. Cloned out so no lock is held across the estimate.
2463        let memory_rendered: Option<String> = self
2464            .pending_memory_segment
2465            .lock()
2466            .unwrap_or_else(std::sync::PoisonError::into_inner)
2467            .as_ref()
2468            .map(|segment| segment.rendered_text().to_string());
2469        let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
2470        context::assess(&context::ContextBudgetInputs {
2471            model: &self.model,
2472            provider_window: self.context_window(),
2473            system_prompt: system.as_deref(),
2474            tools_schema: &schema,
2475            messages,
2476            skill_contents: &[],
2477            memory_contents: &memory_contents,
2478            thinking_budget_tokens: self.thinking_budget as u64,
2479            next_tool_result_bytes: self.max_tool_output as u64,
2480            output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
2481        })
2482    }
2483
2484    /// Task A7: hold a validated recall contribution for the current turn as
2485    /// a typed [`memory_context::ContextSegment`] (spec §10.1 — typed
2486    /// segment, never raw text). Fail-closed acceptance gate:
2487    ///
2488    /// - the §10.3 budget must clear its minimum floor for the session's
2489    ///   provider window ([`memory_context::memory_budget_tokens`]) —
2490    ///   otherwise recall is skipped and nothing is held;
2491    /// - [`memory_context::validate_contribution`] must pass — project
2492    ///   identity (spec §5.2), schema, and bounds — otherwise nothing is
2493    ///   held.
2494    ///
2495    /// Phase A scope: a held segment feeds ONLY the T29 budget lane in
2496    /// [`Self::assess_context`]; per-provider wire injection is task B4.
2497    /// Hold-time uses the provider window as the capacity input — the
2498    /// conservative upper bound available before a request exists; B4's
2499    /// injection path re-checks against the request-aware effective budget.
2500    #[allow(dead_code)] // exercised by tests until task B4 routes real recall
2501    pub(crate) fn hold_memory_contribution(
2502        &self,
2503        contribution: memory_context::MemoryContextContribution,
2504    ) -> std::result::Result<(), memory_context::MemoryContextError> {
2505        let budget = memory_context::memory_budget_tokens(self.context_window())
2506            .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
2507        // Phase A hold path: until task B3 threads a real `RecallRequest`
2508        // through dispatch, the host's conservative default grant applies —
2509        // only baseline model-visible records are accepted back.
2510        memory_context::validate_contribution(
2511            &contribution,
2512            &self.memory_context_project_id(),
2513            budget,
2514            &memory_context::DisclosureGrantSet::model_visible_only(),
2515        )?;
2516        *self
2517            .pending_memory_segment
2518            .lock()
2519            .unwrap_or_else(std::sync::PoisonError::into_inner) =
2520            Some(memory_context::ContextSegment::Memory(contribution));
2521        Ok(())
2522    }
2523
2524    /// Task A7: drop any recall segment held for the current turn. Idempotent.
2525    /// Task B4 calls this on every turn that carries no accepted recall; the
2526    /// disable/revocation path may also use it defensively.
2527    pub(crate) fn clear_memory_contribution(&self) {
2528        *self
2529            .pending_memory_segment
2530            .lock()
2531            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2532    }
2533
2534    /// Task B4 (spec §10.4): explainability metadata retained for the
2535    /// current turn's accepted recall, if any. Metadata only — selected IDs,
2536    /// source classes, rank reasons, byte/token accounting, latency, and
2537    /// withheld/skipped counts; never memory content. Rendered for
2538    /// `/memory why` by [`memory_context::render_recall_why`] (task B5).
2539    pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
2540        self.retained_recall_turn
2541            .lock()
2542            .unwrap_or_else(std::sync::PoisonError::into_inner)
2543            .as_ref()
2544            .map(|retained| retained.why.clone())
2545    }
2546
2547    /// Task B4 (spec §7.4): the ONE provider-agnostic per-prompt recall
2548    /// point, run on the owned turn `messages` BEFORE any per-provider wire
2549    /// translation forks. Delegates every decision to
2550    /// [`memory_context::resolve_turn_recall`] (eligibility, retry-exact
2551    /// reuse, §10.3 budget floor, §16.2 hard timeout, fail-open skips) and
2552    /// then syncs the T29 budget lane:
2553    ///
2554    /// - accepted/reused recall ⇒ the held segment mirrors the injected
2555    ///   synthetic message;
2556    /// - everything else ⇒ the held segment is cleared, so `messages` and
2557    ///   the assessment are byte-identical to the pre-task behavior.
2558    ///
2559    /// Dispatch routes through the granted provider's
2560    /// [`crate::extensions::lease::ExtensionLeaseCapability::call_exact`]
2561    /// (task A6 wiring): the lease's composed `extension:<plugin>:<id>`
2562    /// address plus the plugin's declared recall tool digest — an unroutable
2563    /// provider fails open as `provider_unavailable` without ever spawning.
2564    async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
2565        if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2566            || self
2567                .memory_backend_reconfigure_denied
2568                .load(std::sync::atomic::Ordering::SeqCst)
2569        {
2570            self.clear_memory_contribution();
2571            tracing::debug!("memory recall unavailable for the selected host backend");
2572            return;
2573        }
2574        let extension_runtime = self.extension_runtime.clone();
2575        let binding = self.memory_backend.clone();
2576        let session = self.host_tool_session.clone();
2577        let outcome = memory_context::resolve_turn_recall(
2578            &self.memory_context_state,
2579            &self.retained_recall_turn,
2580            &self.memory_context_project_id(),
2581            self.context_window(),
2582            messages,
2583            memory_context::RECALL_HARD_TIMEOUT,
2584            move |lease: memory_context::MemoryContextLease,
2585                  request: memory_context::RecallRequest| async move {
2586                if binding.is_axel() {
2587                    return axel_context::recall(binding, lease, request).await;
2588                }
2589                let Some(manager) = extension_runtime else {
2590                    return Err(memory_context::RecallCallError::ProviderUnavailable);
2591                };
2592                let mut parts = lease.provider_id.as_str().splitn(3, ':');
2593                let (Some("extension"), Some(plugin), Some(local)) =
2594                    (parts.next(), parts.next(), parts.next())
2595                else {
2596                    return Err(memory_context::RecallCallError::ProviderUnavailable);
2597                };
2598                let Some(digest) =
2599                    manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
2600                else {
2601                    return Err(memory_context::RecallCallError::ProviderUnavailable);
2602                };
2603                let mut response =
2604                    crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
2605                        .call_exact(
2606                            plugin,
2607                            memory_context::MEMORY_RECALL_TOOL_NAME,
2608                            &digest,
2609                            memory_context::recall_request_wire(&request),
2610                        )
2611                        .await
2612                        .map_err(|_| memory_context::RecallCallError::CallFailed)?;
2613                // Legacy extension wire names the local declared provider;
2614                // bind it to the exact host-qualified lease after validation.
2615                if response["provider_id"] != local
2616                    && response["provider_id"] != lease.provider_id.as_str()
2617                {
2618                    return Err(memory_context::RecallCallError::CallFailed);
2619                }
2620                response["provider_id"] = json!(lease.provider_id.as_str());
2621                Ok(response)
2622            },
2623        )
2624        .await;
2625        match outcome {
2626            memory_context::TurnRecallOutcome::Injected
2627            | memory_context::TurnRecallOutcome::ReusedRetained => {
2628                let contribution = self
2629                    .retained_recall_turn
2630                    .lock()
2631                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2632                    .as_ref()
2633                    .map(|retained| retained.contribution.clone());
2634                if let Some(contribution) = contribution {
2635                    *self
2636                        .pending_memory_segment
2637                        .lock()
2638                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
2639                        Some(memory_context::ContextSegment::Memory(contribution));
2640                }
2641            }
2642            memory_context::TurnRecallOutcome::NotEligible => {
2643                self.clear_memory_contribution();
2644            }
2645            memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
2646                self.clear_memory_contribution();
2647                // Bounded metadata diagnostic (spec §13.1) — reason key
2648                // only, never content. Fail open: the turn proceeds.
2649                tracing::debug!(
2650                    reason = reason.as_str(),
2651                    "memory recall skipped; turn proceeds without memory"
2652                );
2653            }
2654        }
2655    }
2656
2657    /// Task B6 harness seam: the EXACT per-prompt recall entry point the
2658    /// stream path runs ([`Self::apply_turn_memory_recall`], the single
2659    /// hook `run_stream_with_messages` calls before any per-provider wire
2660    /// translation), exposed so the headless Phase B end-to-end harness
2661    /// (`tests/memory_context_e2e.rs`) can drive the REAL engine recall
2662    /// flow — real [`crate::extensions::lease::ExtensionLeaseCapability`],
2663    /// real child process, real §16.2 timeout — without provider
2664    /// credentials for the outer HTTP call. Compiled only for tests and
2665    /// `testing` builds, mirroring [`Runtime::new_headless`].
2666    #[cfg(any(test, feature = "testing"))]
2667    pub async fn apply_turn_memory_recall_for_harness(
2668        &self,
2669        messages: &mut Vec<crate::SharedMessage>,
2670    ) {
2671        self.apply_turn_memory_recall(messages).await;
2672    }
2673
2674    /// F1 headless seam for the production C3 terminal-capture path. The caller
2675    /// supplies the canonical completed turn; lease selection, typed building,
2676    /// bounded queueing, and exact extension dispatch are unchanged.
2677    #[cfg(any(test, feature = "testing"))]
2678    pub fn capture_completed_turn_for_harness(
2679        &self,
2680        messages: Vec<crate::SharedMessage>,
2681        turn_ordinal: u64,
2682    ) -> std::result::Result<(), &'static str> {
2683        let started_at = std::time::SystemTime::now();
2684        let lease = self
2685            .memory_context_lock()
2686            .capture_lease_at(started_at)
2687            .ok_or("capture lease unavailable")?;
2688        let provider = self
2689            .extension_capture_provider(&lease)
2690            .ok_or("capture provider unavailable")?;
2691        let mut history = terminal_capture_history(&lease, &messages, started_at);
2692        history.turn_ordinal = turn_ordinal;
2693        memory_capture_worker()
2694            .submit_terminal(
2695                &lease,
2696                history,
2697                memory_context::RetentionClass::Standard,
2698                provider,
2699            )
2700            .map_err(|_| "capture build failed")?;
2701        Ok(())
2702    }
2703
2704    /// F1 headless seam for C4 summary capture with explicit source-range
2705    /// provenance. All identifiers still come from the active host lease.
2706    #[cfg(any(test, feature = "testing"))]
2707    pub fn capture_compaction_summary_for_harness(
2708        &self,
2709        first_turn_ordinal: u64,
2710        last_turn_ordinal: u64,
2711        source_digest: &str,
2712        source_message_count: usize,
2713        summary_text: &str,
2714    ) -> std::result::Result<(), &'static str> {
2715        let summarized_at = std::time::SystemTime::now();
2716        let lease = self
2717            .memory_context_lock()
2718            .capture_lease_at(summarized_at)
2719            .ok_or("capture lease unavailable")?;
2720        let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
2721            .ok_or("invalid source digest")?;
2722        let provider = self
2723            .extension_capture_provider(&lease)
2724            .ok_or("capture provider unavailable")?;
2725        let source = chat_capture::CompactionSource {
2726            schema: chat_capture::CompactionSchemaVersion::V1,
2727            project_id: lease.project_id.clone(),
2728            source_session_id: lease.session_id.clone(),
2729            first_turn_ordinal,
2730            last_turn_ordinal,
2731            source_digest,
2732            summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
2733            prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
2734            redaction: chat_capture::RedactionPolicy::HostRedacted,
2735            content_classes: vec![agent_core::compaction::ContentClass::UserText],
2736            summarized_at,
2737        };
2738        memory_capture_worker()
2739            .submit_summary(
2740                &lease,
2741                source,
2742                source_message_count,
2743                summary_text,
2744                agent_core::compaction::RedactionPolicy::PolicyExclusions,
2745                memory_context::RetentionClass::Standard,
2746                provider,
2747            )
2748            .map_err(|_| "summary capture build failed")?;
2749        Ok(())
2750    }
2751
2752    /// C5 cancellation seam: closes memory recall/capture authority and the
2753    /// backing extension lease synchronously from the caller's perspective.
2754    #[cfg(any(test, feature = "testing"))]
2755    pub fn cancel_memory_forwarding_for_harness(&self) {
2756        self.clear_memory_contribution();
2757        self.memory_context_disable();
2758    }
2759
2760    /// C5 lease-release assertion seam (metadata only).
2761    #[cfg(any(test, feature = "testing"))]
2762    pub fn extension_lease_count_for_harness(&self) -> usize {
2763        self.extension_runtime
2764            .as_ref()
2765            .map_or(0, |manager| manager.lease_count())
2766    }
2767
2768    /// Inherit storage authority only, with a fresh worker execution author.
2769    /// Oneshot, start, and resume share this path; even resume must not reuse
2770    /// a prior actor. Off/no-lease context state and tool grants stay untouched.
2771    pub(crate) fn inherit_memory_backend(&mut self, binding: crate::memory_backend::MemoryBinding) {
2772        self.memory_backend = binding.fork_for_worker();
2773    }
2774
2775    /// Bind standalone host entry points without applying unrelated settings.
2776    pub fn apply_memory_backend_config(&mut self, config: &crate::config::MemoryBackendConfig) {
2777        if let Some(selected) = &self.memory_backend_config {
2778            if selected != config {
2779                self.memory_backend_reconfigure_denied
2780                    .store(true, std::sync::atomic::Ordering::SeqCst);
2781                tracing::warn!("memory backend change denied: live extension processes cannot be reconfigured at runtime; original binding retained; restart required");
2782                self.memory_context_disable();
2783                self.clear_memory_contribution();
2784            }
2785            return;
2786        }
2787        if config.kind != crate::config::MemoryBackendKind::Legacy {
2788            self.memory_context_disable();
2789        }
2790        // merge(112) §4: under the daemon the session cwd differs from
2791        // process cwd; use it when available so project-scope resolution
2792        // lands in the right repo.
2793        self.memory_backend = crate::memory_backend::MemoryBinding::from_config_with_cwd(
2794            config,
2795            self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2796        );
2797        self.memory_backend_config = Some(config.clone());
2798    }
2799
2800    #[cfg(test)]
2801    #[allow(dead_code)] // merge(112): consumed in phase 5
2802    pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2803        self.memory_backend.clone()
2804    }
2805
2806    /// True when legacy note/history/provider access is forbidden by the host
2807    /// selection, including invalid or unavailable backend configuration.
2808    /// Whether the project forum is live for this runtime (Axel backend).
2809    /// Workers use it to decide whether forum guidance belongs in the prompt.
2810    pub fn memory_backend_is_axel(&self) -> bool {
2811        self.memory_backend.is_axel()
2812    }
2813
2814    pub fn memory_backend_exclusive(&self) -> bool {
2815        self.memory_backend.exclusive()
2816    }
2817
2818    fn memory_context_project_id(&self) -> memory_context::ProjectId {
2819        if self.memory_backend.exclusive() {
2820            self.memory_backend
2821                .scope()
2822                .ok()
2823                .and_then(|scope| memory_context::ProjectId::parse(scope.key()).ok())
2824                .unwrap_or_else(|| {
2825                    memory_context::ProjectId::parse("project-unresolved").expect("static id")
2826                })
2827        } else {
2828            memory_project_id()
2829        }
2830    }
2831
2832    pub fn memory_tool_capability(&self) -> Option<memory_context::MemoryContextCapability> {
2833        if !self.memory_backend.exclusive() {
2834            return None;
2835        }
2836        let runtime = self.clone();
2837        Some(memory_context::MemoryContextCapability::control_only(
2838            self.memory_context_state.clone(),
2839            self.memory_context_project_id(),
2840            memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID).expect("static id"),
2841            Arc::new(move || runtime.memory_context_disable()),
2842        ))
2843    }
2844
2845    /// Apply a parsed config file to this runtime (model, thinking budget, etc.)
2846    /// Includes the `disabled_tools` pass on this runtime's registry — the
2847    /// fresh-`Runtime::new()` path, where the registry holds builtins only.
2848    pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2849        self.apply_config_inner(config, true);
2850    }
2851
2852    /// `apply_config` WITHOUT the `disabled_tools` pass. Used by
2853    /// `EngineHost::foreground_runtime`: the host already disabled builtins
2854    /// on the fresh registry before skills/MCP registered (the old boot
2855    /// point), and a second pass here would also strip `load_skill`,
2856    /// `search_skills` and the descriptor-backed MCP tools — which
2857    /// the old boot never did.
2858    pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2859        self.apply_config_inner(config, false);
2860    }
2861
2862    fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2863        // #112: apply memory backend config early (binding is immutable once set).
2864        self.apply_memory_backend_config(&config.memory_backend);
2865        if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2866            || self
2867                .memory_backend_reconfigure_denied
2868                .load(std::sync::atomic::Ordering::SeqCst)
2869        {
2870            // Unavailable selections revoke grants; never retain an extension escape hatch.
2871            self.memory_context_disable();
2872            self.clear_memory_contribution();
2873            *self
2874                .retained_recall_turn
2875                .lock()
2876                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2877        }
2878        if let Some(ref model) = config.model {
2879            self.set_model(model.clone());
2880        }
2881        // Named level takes priority over raw budget.
2882        // Config-specified thinking is an explicit user choice — preserve across model switches.
2883        if let Some(level) = config.thinking_level {
2884            self.set_reasoning_level_explicit(level);
2885        } else if let Some(budget) = config.thinking_budget {
2886            self.set_thinking_budget_explicit(budget);
2887        }
2888        // A configured level the configured model rejects would boot into a
2889        // permanently failing state; clamp exactly as `/model` does.
2890        if let Some(clamp) = self.clamp_reasoning_to_model() {
2891            tracing::warn!(
2892                from = clamp.from.as_str(),
2893                to = clamp.to.as_str(),
2894                model = %self.model,
2895                "configured reasoning level not supported by model; clamped"
2896            );
2897        }
2898        self.context_window_override = config.context_window;
2899        self.continuation
2900            .lock()
2901            .unwrap_or_else(std::sync::PoisonError::into_inner)
2902            .config = config.context_management;
2903        self.compaction_model = config.compaction_model.clone();
2904        self.compaction_mode = config.compaction_mode;
2905        self.compaction_exclusions = config.compaction_exclude.clone();
2906        self.max_tool_output = config.max_tool_output;
2907        self.bash_timeout = config.bash_timeout;
2908        self.bash_max_timeout = config.bash_max_timeout;
2909        self.subagent_timeout = config.subagent_timeout;
2910        self.api_retries = config.api_retries;
2911        self.refusal_retries = config.refusal_retries;
2912        self.telemetry_level =
2913            crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2914        self.sync_observability();
2915        self.cache_diagnostics = config.cache_diagnostics;
2916        self.cache_ttl = config.cache_ttl;
2917        self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2918        self.deferred_tool_loading = config.tools_deferred_loading;
2919        self.activation_confirm = config.tools_activation_confirm;
2920        tracing::info!(
2921            mode = config.tools_activation_confirm.as_str(),
2922            auto_approve_confirms = config.server.auto_approve_confirms,
2923            "tools.activation_confirm: model-initiated activate_tools policy"
2924        );
2925        self.trusted_worker_models = config
2926            .favorite_models
2927            .iter()
2928            .filter_map(|model| {
2929                let canonical = canonical_trusted_worker_model(model);
2930                match agent_core::prompt::QualifiedModelId::parse(canonical) {
2931                    Ok(model) => Some(model),
2932                    Err(_) => {
2933                        tracing::warn!(
2934                            model = model.as_str(),
2935                            "ignoring invalid favorite worker-model identity"
2936                        );
2937                        None
2938                    }
2939                }
2940            })
2941            .collect();
2942        self.trusted_worker_models.sort();
2943        self.trusted_worker_models.dedup();
2944        self.subagent_limits = config.subagent;
2945        if let Some(orchestration) = &self.orchestration {
2946            for model in &self.trusted_worker_models {
2947                if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2948                    tracing::warn!(
2949                        model = model.as_str(),
2950                        error = %error,
2951                        "failed to apply configured worker-model trust"
2952                    );
2953                }
2954            }
2955        }
2956        self.apply_auth_config(config);
2957
2958        // Remove any built-in tools the user disabled via `disabled_tools`.
2959        // try_write is safe here: this runs on a fresh runtime's private
2960        // registry before it is shared with other tasks.
2961        if disable_tools && !config.disabled_tools.is_empty() {
2962            if let Ok(mut reg) = self.tools.try_write() {
2963                reg.disable(&config.disabled_tools);
2964            }
2965        }
2966    }
2967
2968    /// Apply only the credential-source portion of config. Used by subagent
2969    /// spawns that build a fresh `Runtime::new()` (which defaults to Local) and
2970    /// must inherit the user's Remote/broker setup. (#158 A3)
2971    ///
2972    /// A6: invalidates the token cache if the source/endpoint changed.
2973    /// A2: scrubs `AuthState` when Remote so no local `auth.json` credential is
2974    /// used or held (invariant 1).
2975    pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2976        let new_source = config.auth.credential_source();
2977        if new_source != self.credential_source {
2978            self.token_cache.invalidate("anthropic");
2979        }
2980        self.credential_source = new_source;
2981        if self.credential_source.is_remote() {
2982            if let Ok(mut auth) = self.auth.try_write() {
2983                AuthMethods::scrub_for_remote(&mut auth);
2984            }
2985        }
2986        // Account switch (G5): if the in-memory Anthropic OAuth token is
2987        // bound to a credential other than the one this config now selects
2988        // (`auth.account.anthropic` / `SYNAPS_ACCOUNT_ANTHROPIC`, or a
2989        // source change), drop it so the next turn re-vends through the
2990        // broker — the previous account is never silently kept. Stateless:
2991        // compares the token's recorded binding to the new selection.
2992        if let Ok(mut auth) = self.auth.try_write() {
2993            if auth.auth_type == "oauth" && !auth.auth_token.is_empty() {
2994                let selector = config
2995                    .auth
2996                    .account_selector(crate::auth::OAuthProviderId::Anthropic);
2997                let check = crate::runtime::auth::check_binding(
2998                    &self.credential_source,
2999                    &selector,
3000                    auth.bound_credential.as_deref(),
3001                    crate::runtime::auth::RefreshPoint::TurnStart,
3002                );
3003                if check != crate::runtime::auth::BindingCheck::Matches {
3004                    AuthMethods::scrub_for_account_switch(&mut auth);
3005                    self.token_cache.invalidate("anthropic");
3006                }
3007            }
3008        }
3009        // Install the process-wide credential broker matching this source so
3010        // every request path (streams, pings, catalog, TUI status) resolves
3011        // credentials through the same boundary. Local sources get the
3012        // in-process broker — no separately launched daemon required.
3013        crate::auth::set_global_broker(crate::auth::broker_from_source(
3014            &self.credential_source,
3015            &self.token_cache,
3016            self.client.clone(),
3017        ));
3018    }
3019
3020    pub fn thinking_budget(&self) -> u32 {
3021        self.thinking_budget
3022    }
3023
3024    pub fn max_tool_output(&self) -> usize {
3025        self.max_tool_output
3026    }
3027
3028    pub fn bash_timeout(&self) -> u64 {
3029        self.bash_timeout
3030    }
3031
3032    pub fn bash_max_timeout(&self) -> u64 {
3033        self.bash_max_timeout
3034    }
3035
3036    pub fn subagent_timeout(&self) -> u64 {
3037        self.subagent_timeout
3038    }
3039
3040    pub fn api_retries(&self) -> u32 {
3041        self.api_retries
3042    }
3043
3044    pub fn set_max_tool_output(&mut self, v: usize) {
3045        self.max_tool_output = v;
3046    }
3047
3048    pub fn set_bash_timeout(&mut self, v: u64) {
3049        self.bash_timeout = v;
3050    }
3051
3052    pub fn set_bash_max_timeout(&mut self, v: u64) {
3053        self.bash_max_timeout = v;
3054    }
3055
3056    pub fn set_subagent_timeout(&mut self, v: u64) {
3057        self.subagent_timeout = v;
3058    }
3059
3060    /// Copy only retry policy; worker construction must not re-install brokers
3061    /// or acquire foreground capabilities by calling full apply_config.
3062    pub(crate) fn apply_retry_config(&mut self, config: &crate::SynapsConfig) {
3063        self.api_retries = config.api_retries;
3064        self.refusal_retries = config.refusal_retries;
3065    }
3066
3067    pub fn refusal_retries(&self) -> u32 {
3068        self.refusal_retries
3069    }
3070
3071    pub fn set_api_retries(&mut self, v: u32) {
3072        self.api_retries = v;
3073    }
3074
3075    pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
3076        self.telemetry_level
3077    }
3078
3079    pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
3080        self.telemetry_level = level;
3081        self.sync_observability();
3082    }
3083
3084    /// This session's telemetry, for the subagents it spawns.
3085    pub fn worker_telemetry(&self) -> crate::runtime::telemetry::WorkerTelemetry {
3086        crate::runtime::telemetry::WorkerTelemetry {
3087            level: self.telemetry_level,
3088            writer: self.telemetry_writer.clone(),
3089        }
3090    }
3091
3092    /// Record a worker's requests at its parent's telemetry level, through
3093    /// the parent's session writer. Worker runtimes start `Off`
3094    /// (`from_parts`), so without this every subagent request was missing
3095    /// from `api-log.jsonl`.
3096    ///
3097    /// The writer is shared, not rebuilt: it owns its files from one thread,
3098    /// and a writer per worker would race rotation. The trace context is
3099    /// fresh, though: a worker is its own trace session with its own
3100    /// cache-break snapshots, so its requests never read as breaks in the
3101    /// parent's conversation. A parent that is `Off` leaves the worker `Off`.
3102    pub fn inherit_telemetry(&mut self, parent: &crate::runtime::telemetry::WorkerTelemetry) {
3103        match (&parent.writer, parent.level.enabled()) {
3104            (Some(writer), true) => {
3105                self.telemetry_level = parent.level;
3106                self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3107                    crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3108                ));
3109                self.telemetry_writer = Some(writer.clone());
3110            }
3111            _ => self.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off),
3112        }
3113    }
3114
3115    /// Reconcile the shared observability writer + trace sink with the
3116    /// telemetry level (Task 11 config rule, documented until Task 12 adds
3117    /// explicit trace config/UI): `basic`/`full` enables BOTH legacy
3118    /// telemetry persistence and metadata-only trace persistence through
3119    /// one bounded session writer; `off` disables both. The trace schema is
3120    /// structurally metadata-only, so this never persists raw content.
3121    fn sync_observability(&mut self) {
3122        if self.telemetry_level.enabled() {
3123            if self.telemetry_writer.is_none() {
3124                let writer = crate::runtime::telemetry::TelemetryWriter::new(
3125                    crate::runtime::telemetry::WriterOptions::default(),
3126                );
3127                self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3128                    crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3129                ));
3130                self.telemetry_writer = Some(writer);
3131            }
3132        } else {
3133            // Dropping this handle detaches the worker: it drains whatever
3134            // is queued and exits on its own — never a hang. In-flight
3135            // requests holding a cloned handle keep enqueueing harmlessly.
3136            self.telemetry_writer = None;
3137            self.trace_ctx = trace::TraceContext::disabled();
3138        }
3139    }
3140
3141    /// The session trace context handed to every request (cheap clone).
3142    pub fn trace_context(&self) -> trace::TraceContext {
3143        self.trace_ctx.clone()
3144    }
3145
3146    /// Effective trace context for the next outgoing model request:
3147    /// consumes a pending one-shot arm (`/trace next [content]`, Task 12).
3148    ///
3149    /// One-shot semantics (B1): the arm covers exactly one *logical
3150    /// request* — the returned armed context carries a one-shot request
3151    /// gate consumed inside `RequestTracer::begin`, so the first request
3152    /// through it (all retry attempts included) emits records and every
3153    /// subsequent request sharing the same `ApiOptions` (tool-loop
3154    /// continuations) is disabled. Normal Basic/Full session contexts are
3155    /// never gated.
3156    ///
3157    /// When the session sink is disabled the one-shot record rides an
3158    /// ephemeral writer forked from the session context (same session ID,
3159    /// digest key, degradation counters, and §6.6 cache snapshot store —
3160    /// so `/context` sees the armed request's diagnostics). The writer
3161    /// handle is retained in `one_shot_trace_writer` until replaced or
3162    /// drained by the shutdown epilogue, guaranteeing the armed record
3163    /// flushes on session exit. With the session sink already enabled
3164    /// (telemetry Basic/Full) the arm only adds the optional content
3165    /// capture.
3166    pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
3167        match self.trace_controls.consume() {
3168            None => self.trace_ctx.clone(),
3169            Some(with_content) => {
3170                let base = if self.trace_ctx.enabled() {
3171                    self.trace_ctx.clone()
3172                } else {
3173                    let writer = crate::runtime::telemetry::TelemetryWriter::new(
3174                        crate::runtime::telemetry::WriterOptions::default(),
3175                    );
3176                    // Retain the writer for the exit epilogue (drops any
3177                    // previously retained one-shot writer, whose worker
3178                    // keeps draining in the background).
3179                    *self
3180                        .one_shot_trace_writer
3181                        .lock()
3182                        .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
3183                    self.trace_ctx
3184                        .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
3185                            writer,
3186                        )))
3187                        .with_one_shot_request_gate()
3188                };
3189                if with_content {
3190                    base.with_content_capture(Arc::new(trace::ContentCapture::new(
3191                        self.capture_dir.clone(),
3192                    )))
3193                } else {
3194                    base
3195                }
3196            }
3197        }
3198    }
3199
3200    /// Arm tracing for exactly the next outgoing provider request
3201    /// (`/trace next`; `with_content` adds the one-request redacted
3202    /// content capture for `/trace next content`).
3203    pub fn trace_arm_next(&self, with_content: bool) {
3204        self.trace_controls.arm_next(with_content);
3205    }
3206
3207    /// Metadata-only `/trace status` report: mode, persistence path,
3208    /// counters. Never secrets or content. Also performs the opportunistic
3209    /// expired-capture sweep (B2): status is a trace interaction, so stale
3210    /// content-capture bundles are physically removed here too.
3211    pub fn trace_status(&self) -> trace::TraceStatusReport {
3212        let _ = trace::sweep_expired_captures(&self.capture_dir);
3213        trace::TraceStatusReport {
3214            persistent_enabled: self.telemetry_writer.is_some(),
3215            arm: self.trace_controls.peek(),
3216            trace_path: crate::runtime::telemetry::default_trace_log_path(),
3217            writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3218            degraded_records: self.trace_ctx.degraded_records(),
3219        }
3220    }
3221
3222    /// Lock the session-scoped memory-context state (task A5). Poison
3223    /// recovery is sound for the same reason as
3224    /// `memory_context::MemoryContextCapability::lock`: every
3225    /// `SessionMemoryState` transition is check-then-single-assignment with
3226    /// no panicking code between, so a poisoned lock still holds a
3227    /// consistent state.
3228    fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
3229        self.memory_context_state
3230            .lock()
3231            .unwrap_or_else(std::sync::PoisonError::into_inner)
3232    }
3233
3234    /// Typed `/memory status` snapshot — metadata-only, never spawns a
3235    /// provider process (spec §7.2 "status does not spawn").
3236    pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
3237        self.memory_context_lock().status()
3238    }
3239
3240    /// Revoke this session's memory context (`/memory off`). Always locally
3241    /// allowed (spec §7.2); idempotent — disabling an already-off session
3242    /// reports `Off`.
3243    ///
3244    /// Task A6 revocation wiring: after the session state transition, the
3245    /// extension runtime lease backing each bound provider is defensively
3246    /// revoked through the SAME [`ExtensionRuntimeManager`] mechanism the
3247    /// deferred tool/handler lifecycle uses ([`Self::install_extension_runtime`]
3248    /// / `ExtensionSessionEndGuard`). While Phase A never spawns, this is an
3249    /// idempotent no-op; once Phase B routes real recall/capture calls under
3250    /// this runtime's tool session, disable reaps the provider child exactly.
3251    ///
3252    /// [`ExtensionRuntimeManager`]: crate::extensions::lease::ExtensionRuntimeManager
3253    pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
3254        let (status, bound, session) = {
3255            let mut state = self.memory_context_lock();
3256            let session = state.session_id().clone();
3257            let bound = state.bound_provider_ids();
3258            let status = match memory_context::apply_memory_context_action(
3259                &mut state,
3260                memory_context::AuthorizedMemoryAction::Disable {
3261                    session: session.clone(),
3262                },
3263            ) {
3264                Ok(status) => status,
3265                // Unreachable by construction (the session identity is read
3266                // from the state itself and `Disable` is total), but never
3267                // panic on the fallback: report current status.
3268                Err(_) => state.status(),
3269            };
3270            (status, bound, session)
3271        };
3272        // §15 `memory_context.disabled` (task B5): emitted after the state
3273        // transition applied — session + project digest identities only.
3274        memory_context::emit_memory_observability_event(
3275            &memory_context::MemoryObservabilityEvent::context_disabled(
3276                &session,
3277                &self.memory_context_project_id(),
3278            ),
3279        );
3280        // State mutex released before touching the lease manager: the
3281        // revocation below locks only the lease map (idempotent no-op when
3282        // nothing was ever spawned).
3283        self.revoke_memory_provider_leases(&bound);
3284        status
3285    }
3286
3287    /// Task A6: defensively revoke the extension runtime lease backing each
3288    /// bound memory provider for THIS runtime's tool session. Confirmed
3289    /// idempotent — `ExtensionRuntimeManager::revoke_plugin_lease` is a map
3290    /// remove that finds nothing when no lease was ever acquired, so calling
3291    /// it before Phase B ever spawns is a safe no-op. Provider identities
3292    /// that are not composed `extension:<plugin>:<id>` runtime addresses
3293    /// (the legacy canonical fallback) name no plugin child and are skipped.
3294    fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
3295        let Some(extension_runtime) = &self.extension_runtime else {
3296            return;
3297        };
3298        for provider in bound {
3299            let mut parts = provider.as_str().splitn(3, ':');
3300            if let (Some("extension"), Some(plugin), Some(_local_id)) =
3301                (parts.next(), parts.next(), parts.next())
3302            {
3303                if !plugin.is_empty() {
3304                    extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
3305                }
3306            }
3307        }
3308    }
3309
3310    /// Task A6 enable-time provider validation: resolve the provider a
3311    /// `/memory` grant binds to. With the extension runtime installed
3312    /// ([`Self::install_extension_runtime`] — the same wiring point that
3313    /// backs `extension_leases` capabilities), resolution is MANDATORY and
3314    /// fail-closed against the loaded context-provider catalog (pure
3315    /// catalog read, never spawns): exactly one matching declared provider
3316    /// resolves to its composed `extension:<plugin>:<id>` runtime address;
3317    /// zero matches fail [`memory_context::MemoryContextError::ProviderNotRegistered`]
3318    /// and overlapping declarations without an exact requested id fail
3319    /// [`memory_context::MemoryContextError::ProviderAmbiguous`] — the
3320    /// exact-scope precedent of `validate_deferred` and
3321    /// `crate::orchestration::validate_user_authorizable_model`.
3322    ///
3323    /// Without an extension subsystem (legacy/flag-off constructions and
3324    /// headless unit runtimes) the A5 spec-canonical identity is kept:
3325    /// there is no catalog to validate against, Phase A grants are inert
3326    /// metadata, and Phase B activation independently re-validates through
3327    /// `ExtensionLeaseCapability::call_exact` against retained launch
3328    /// records — an unbacked identity can never start a process.
3329    fn resolve_memory_provider(
3330        &self,
3331        requested: Option<&str>,
3332    ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
3333    {
3334        use crate::extensions::context_provider as ext_cp;
3335        if memory_context::provider_unavailable_for_test() {
3336            return Err(memory_context::MemoryContextError::ProviderNotRegistered);
3337        }
3338        if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3339            || self
3340                .memory_backend_reconfigure_denied
3341                .load(std::sync::atomic::Ordering::SeqCst)
3342        {
3343            // Invalid exclusive configurations never fall back to extensions.
3344            return Err(memory_context::MemoryContextError::ProviderNotRegistered);
3345        }
3346        if self.memory_backend.is_axel() {
3347            self.memory_backend
3348                .validate_axel_config()
3349                .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?;
3350            if requested.is_some_and(|id| id != axel_context::PROVIDER_ID) {
3351                return Err(memory_context::MemoryContextError::ProviderNotRegistered);
3352            }
3353            return memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID);
3354        }
3355        let Some(extension_runtime) = &self.extension_runtime else {
3356            return Ok(memory_provider_id());
3357        };
3358        let requested_id = match requested {
3359            None => None,
3360            // An id that cannot even parse as a declared provider identity
3361            // can never be registered: fail closed as not-registered.
3362            Some(raw) => Some(
3363                ext_cp::ContextProviderId::parse(raw)
3364                    .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
3365            ),
3366        };
3367        match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
3368            Ok(descriptor) => {
3369                memory_context::ContextProviderId::parse(&descriptor.runtime_address())
3370            }
3371            Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
3372                Err(memory_context::MemoryContextError::ProviderNotRegistered)
3373            }
3374            Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
3375                Err(memory_context::MemoryContextError::ProviderAmbiguous)
3376            }
3377        }
3378    }
3379
3380    /// Install a durable session-lease memory mode (`/memory on|recall|
3381    /// capture`, spec §7.3) under the caller-supplied host-owned intent
3382    /// proof (spec §6.3). Task A6: the target provider is resolved and
3383    /// validated FIRST ([`Self::resolve_memory_provider`], fail-closed) —
3384    /// then the lease is minted and the exhaustive
3385    /// [`memory_context::apply_memory_context_action`] transition applied.
3386    /// On any typed failure no lease is installed.
3387    pub(crate) fn memory_context_enable(
3388        &self,
3389        mode: memory_context::MemoryContextMode,
3390        proof: memory_context::UserIntentProof,
3391    ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
3392    {
3393        self.memory_context_enable_resolved(mode, proof, None)
3394    }
3395
3396    /// [`Self::memory_context_enable`] with an explicit requested provider
3397    /// id (exact declared id; `None` requires a uniquely-declared provider
3398    /// when the extension subsystem is installed). Crate-internal: the
3399    /// `/memory` frontend forms currently pass `None`.
3400    pub(crate) fn memory_context_enable_resolved(
3401        &self,
3402        mode: memory_context::MemoryContextMode,
3403        proof: memory_context::UserIntentProof,
3404        requested_provider: Option<&str>,
3405    ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
3406    {
3407        let mut state = self.memory_context_lock();
3408        let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
3409        // §15 `memory_context.enabled` (task B5): metadata-only snapshot of
3410        // the lease identities, captured before `apply` consumes the lease
3411        // and emitted only on a successful install.
3412        let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
3413        let result = memory_context::apply_memory_context_action(
3414            &mut state,
3415            memory_context::AuthorizedMemoryAction::Enable { lease },
3416        );
3417        if result.is_ok() {
3418            memory_context::emit_memory_observability_event(&enabled_event);
3419        }
3420        result
3421    }
3422
3423    /// Start this fresh foreground session in the operator's confirmed
3424    /// `memory.default_mode` (spec §12). Called once per session runtime by
3425    /// [`crate::host::EngineHost::foreground_runtime_for`], after the memory
3426    /// backend is bound; workers never call it, so they inherit storage but
3427    /// not the default lease. The grant takes the exact `/memory` path —
3428    /// provider resolution and validation first, fail-closed — under
3429    /// [`memory_context::UserIntentProof::OperatorConfig`].
3430    ///
3431    /// `None`: nothing to apply (default off, consent latch unset, or the
3432    /// session already has memory state). `/memory off` still revokes the
3433    /// default for the rest of the session.
3434    pub(crate) fn apply_memory_default_mode(
3435        &self,
3436        config: &agent_core::config::MemoryConfig,
3437    ) -> Option<
3438        std::result::Result<
3439            memory_context::MemoryContextStatus,
3440            memory_context::MemoryContextError,
3441        >,
3442    > {
3443        use memory_context::MemoryContextMode as Mode;
3444        // The parser already reverts an unconfirmed mode to off; re-check so
3445        // a directly constructed config can never fail open.
3446        if !config.default_mode_confirmed {
3447            return None;
3448        }
3449        let mode = match config.default_mode.as_str() {
3450            "recall_once" => Mode::RecallOnce,
3451            "recall_each_prompt" => Mode::RecallEachPrompt,
3452            "capture_only" => Mode::CaptureOnly,
3453            "capture_and_recall" => Mode::CaptureAndRecall,
3454            _ => return None,
3455        };
3456        if self.memory_context_status().durable != memory_context::DurableStatus::Off {
3457            return None;
3458        }
3459        let proof = memory_context::UserIntentProof::OperatorConfig;
3460        let result = if mode == Mode::RecallOnce {
3461            self.memory_context_recall_once(proof)
3462        } else {
3463            self.memory_context_enable(mode, proof)
3464        };
3465        match &result {
3466            Ok(_) => tracing::info!(
3467                mode = mode.as_str(),
3468                "memory.default_mode applied to session"
3469            ),
3470            Err(e) => tracing::warn!(
3471                mode = mode.as_str(),
3472                error = %e,
3473                "memory.default_mode not applied; session starts with memory off"
3474            ),
3475        }
3476        Some(result)
3477    }
3478
3479    /// Restore a session's own `/memory` choice on a rebuilt runtime (unpark,
3480    /// daemon reload) instead of the operator default. The choice was the
3481    /// user's explicit command for this session, so it is re-granted under
3482    /// the same `ExplicitCommand` proof the command minted; `Off` grants
3483    /// nothing (the runtime was built without the default). A failed grant
3484    /// is logged and leaves memory off.
3485    pub(crate) fn restore_session_memory_mode(&self, mode: crate::session::SessionMemoryMode) {
3486        let Some(lease) = mode.lease_mode() else {
3487            return;
3488        };
3489        if let Err(e) =
3490            self.memory_context_enable(lease, memory_context::mint_explicit_command_proof())
3491        {
3492            tracing::warn!(
3493                mode = lease.as_str(),
3494                error = %e,
3495                "session memory mode not restored; memory stays off"
3496            );
3497        }
3498    }
3499
3500    /// Install a one-shot recall lease (`/memory once`, spec §7.3) under
3501    /// the caller-supplied host-owned intent proof. Provider-validated
3502    /// exactly like [`Self::memory_context_enable`] (task A6). Fails typed
3503    /// (e.g. a one-shot already pending) without installing anything.
3504    pub(crate) fn memory_context_recall_once(
3505        &self,
3506        proof: memory_context::UserIntentProof,
3507    ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
3508    {
3509        let mut state = self.memory_context_lock();
3510        let lease = self.grant_command_memory_lease(
3511            &state,
3512            memory_context::MemoryContextMode::RecallOnce,
3513            proof,
3514            None,
3515        )?;
3516        // §15 `memory_context.enabled` (task B5): the one-shot grant is an
3517        // enable of `recall_once` — emitted only on a successful install.
3518        let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
3519        let result = memory_context::apply_memory_context_action(
3520            &mut state,
3521            memory_context::AuthorizedMemoryAction::RecallOnce { lease },
3522        );
3523        if result.is_ok() {
3524            memory_context::emit_memory_observability_event(&enabled_event);
3525        }
3526        result
3527    }
3528
3529    /// Mint one host-owned lease for a `/memory` command grant: host-minted
3530    /// lease ID, the state's own session identity, host-derived project
3531    /// identity, and the task-A6 catalog-validated provider identity
3532    /// ([`Self::resolve_memory_provider`] — fail-closed; on a resolution
3533    /// error nothing is minted). Session leases carry no hard expiry (until
3534    /// revoked or session end); `/memory` has no expiry argument (spec §7.3).
3535    fn grant_command_memory_lease(
3536        &self,
3537        state: &memory_context::SessionMemoryState,
3538        mode: memory_context::MemoryContextMode,
3539        proof: memory_context::UserIntentProof,
3540        requested_provider: Option<&str>,
3541    ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
3542    {
3543        let provider_id = self.resolve_memory_provider(requested_provider)?;
3544        memory_context::MemoryContextLease::grant(
3545            memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
3546            state.session_id().clone(),
3547            self.memory_context_project_id(),
3548            provider_id,
3549            mode,
3550            memory_context::CapturePolicy::default(),
3551            memory_context::RecallPolicy::default(),
3552            proof,
3553            std::time::SystemTime::now(),
3554            None,
3555        )
3556    }
3557
3558    #[cfg(test)]
3559    pub(crate) fn set_capture_provider_for_test(
3560        &self,
3561        provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
3562    ) {
3563        *self
3564            .capture_provider_for_test
3565            .lock()
3566            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
3567    }
3568
3569    pub(crate) fn compaction_capture_lease_at(
3570        &self,
3571        now: std::time::SystemTime,
3572    ) -> Option<memory_context::MemoryContextLease> {
3573        self.memory_context_lock().capture_lease_at(now)
3574    }
3575
3576    pub(crate) fn submit_compaction_summary_capture(
3577        &self,
3578        capture_lease: Option<memory_context::MemoryContextLease>,
3579        current: &agent_core::session::Session,
3580        api_messages: &[crate::SharedMessage],
3581        outcome: &compaction::CompactionOutcome,
3582        summarized_at: std::time::SystemTime,
3583    ) {
3584        let Some(lease) = capture_lease else {
3585            return;
3586        };
3587        let Ok(source_session_id) = memory_context::SessionId::parse(&current.id) else {
3588            return;
3589        };
3590        // A compaction source range is the exact pre-transition message range.
3591        // Legacy session messages have no durable turn ordinals, so this
3592        // inclusive range uses canonical message positions and the digest
3593        // binds the exact ordered content.
3594        if api_messages.is_empty() {
3595            return;
3596        }
3597        let first_turn_ordinal = 0;
3598        let last_turn_ordinal = (api_messages.len() - 1) as u64;
3599        let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
3600            &agent_core::compaction::message_range_digest(api_messages),
3601        ) else {
3602            return;
3603        };
3604        let Some(prompt_stack_digest) =
3605            chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
3606        else {
3607            return;
3608        };
3609        let summary_origin = if outcome.local_only {
3610            chat_capture::CompactionSummaryOrigin::LocalOnly
3611        } else {
3612            chat_capture::CompactionSummaryOrigin::Provider {
3613                provider_id: outcome.summary_provider.clone(),
3614                model_id: outcome.summary_model.clone(),
3615            }
3616        };
3617        let content_classes = outcome.included_classes.clone();
3618        let source = chat_capture::CompactionSource {
3619            schema: chat_capture::CompactionSchemaVersion::V1,
3620            project_id: lease.project_id.clone(),
3621            source_session_id,
3622            first_turn_ordinal,
3623            last_turn_ordinal,
3624            source_digest,
3625            summary_origin,
3626            prompt_stack_digest,
3627            redaction: match outcome.redaction_policy {
3628                agent_core::compaction::RedactionPolicy::TruncationOnly => {
3629                    chat_capture::RedactionPolicy::None
3630                }
3631                agent_core::compaction::RedactionPolicy::PolicyExclusions => {
3632                    chat_capture::RedactionPolicy::HostRedacted
3633                }
3634            },
3635            content_classes,
3636            summarized_at,
3637        };
3638
3639        #[cfg(any(test, feature = "testing"))]
3640        let test_provider = self
3641            .capture_provider_for_test
3642            .lock()
3643            .unwrap_or_else(std::sync::PoisonError::into_inner)
3644            .clone();
3645        #[cfg(not(any(test, feature = "testing")))]
3646        let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
3647        let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
3648        if let Some(provider) = provider {
3649            let _ = memory_capture_worker().submit_summary(
3650                &lease,
3651                source,
3652                api_messages.len(),
3653                &outcome.summary_text,
3654                outcome.redaction_policy,
3655                memory_context::RetentionClass::Standard,
3656                provider,
3657            );
3658        }
3659    }
3660
3661    fn extension_capture_provider(
3662        &self,
3663        lease: &memory_context::MemoryContextLease,
3664    ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
3665        if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3666            || self
3667                .memory_backend_reconfigure_denied
3668                .load(std::sync::atomic::Ordering::SeqCst)
3669        {
3670            return None;
3671        }
3672        if self.memory_backend.is_axel() {
3673            if lease.provider_id.as_str() != axel_context::PROVIDER_ID
3674                || lease.project_id != self.memory_context_project_id()
3675            {
3676                return None;
3677            }
3678            return Some(Arc::new(axel_context::AxelCaptureProvider {
3679                binding: self.memory_backend.clone(),
3680                state: self.memory_context_state.clone(),
3681                lease: lease.clone(),
3682            }));
3683        }
3684        let manager = self.extension_runtime.clone()?;
3685        let mut parts = lease.provider_id.as_str().splitn(3, ':');
3686        let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
3687        else {
3688            return None;
3689        };
3690        let digest =
3691            manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
3692        let handle = tokio::runtime::Handle::try_current().ok()?;
3693        Some(std::sync::Arc::new(ExtensionCaptureProvider {
3694            manager,
3695            session: self.host_tool_session.clone(),
3696            plugin: plugin.to_owned(),
3697            digest,
3698            handle,
3699        }))
3700    }
3701
3702    /// Return the history-import disclosure preview. This is D1's production
3703    /// entry point: metadata is scanned, but session bodies and Axel are never
3704    /// opened. All frontends share this runtime implementation.
3705    pub fn memory_history_preview(
3706        &self,
3707    ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
3708    {
3709        let host = memory_history::HistoryImportHostState::from_current_host()?;
3710        let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3711        let preview = memory_history::preview_history_import(&host, &mut io)?;
3712        *self
3713            .pending_history_import_preview
3714            .lock()
3715            .expect("history preview mutex poisoned") = Some(preview.clone());
3716        Ok(preview)
3717    }
3718
3719    /// Bind the pending, already-presented history preview to an explicit
3720    /// frontend command. D1 returns only a typed plan; D2 owns all content
3721    /// streaming.
3722    pub(crate) fn memory_history_confirm(
3723        &self,
3724    ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
3725    {
3726        let preview = self
3727            .pending_history_import_preview
3728            .lock()
3729            .expect("history preview mutex poisoned")
3730            .take()
3731            .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
3732        let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
3733            memory_context::mint_explicit_command_id(),
3734        );
3735        let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3736        match memory_history::authorize_history_import(
3737            preview,
3738            memory_history::HistoryImportConsent::Confirmed(intent),
3739            memory_history::RequestAuthority::UserFrontend,
3740            &mut io,
3741        )? {
3742            memory_history::HistoryImportOutcome::Ready(plan) => {
3743                *self
3744                    .history_import_plan
3745                    .lock()
3746                    .expect("history plan mutex poisoned") = Some(plan.clone());
3747                let lease = self
3748                    .memory_context_lock()
3749                    .capture_lease_at(std::time::SystemTime::now())
3750                    .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
3751                // This resolves only a manifest-declared extension sidecar. No
3752                // HTTP client or network transport is built by history import.
3753                let provider = self
3754                    .extension_capture_provider(&lease)
3755                    .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
3756                let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
3757                memory_history::import_history_from_dir(
3758                    &plan,
3759                    &lease,
3760                    &sessions_dir,
3761                    memory_history::IMPORT_BATCH_MAX_RECORDS,
3762                    memory_capture_worker(),
3763                    provider,
3764                )
3765            }
3766            memory_history::HistoryImportOutcome::Declined => {
3767                Err(memory_history::HistoryImportError::ConsentRequired)
3768            }
3769        }
3770    }
3771
3772    /// Decline and clear a pending preview without any content reads or writes.
3773    pub(crate) fn memory_history_decline(&self) -> bool {
3774        self.pending_history_import_preview
3775            .lock()
3776            .expect("history preview mutex poisoned")
3777            .take()
3778            .is_some()
3779    }
3780
3781    /// Test-only: intent proof recorded on the current durable lease.
3782    #[cfg(test)]
3783    pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3784        self.memory_context_lock().durable_proof().cloned()
3785    }
3786
3787    /// Test-only: intent proof recorded on the pending one-shot lease.
3788    #[cfg(test)]
3789    pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3790        self.memory_context_lock().one_shot_pending_proof().cloned()
3791    }
3792
3793    /// Test-only: provider identities bound to the currently granted
3794    /// leases (durable + pending one-shot), in that order.
3795    #[cfg(test)]
3796    pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
3797        self.memory_context_lock().bound_provider_ids()
3798    }
3799
3800    /// Structured `/context` report (Task 12): counts, byte lengths, cache
3801    /// change/reuse estimates, and writer counters — never content.
3802    ///
3803    /// `history` is the conversation owned by the calling surface (TUI /
3804    /// headless); when it is not provided the history lines honestly read
3805    /// `unavailable` rather than fabricating zeros. Loaded skills/memories
3806    /// are session-surface state the runtime cannot enumerate, so they are
3807    /// reported `unavailable` with that provenance.
3808    pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
3809        use trace::ReportValue;
3810        let (history_messages, history_bytes) = match history {
3811            Some(msgs) => (
3812                ReportValue::Count(msgs.len() as u64),
3813                ReportValue::Count(
3814                    msgs.iter()
3815                        .map(|m| {
3816                            serde_json::to_vec(&**m)
3817                                .map(|v| v.len() as u64)
3818                                .unwrap_or(0)
3819                        })
3820                        .sum(),
3821                ),
3822            ),
3823            None => (ReportValue::Unavailable, ReportValue::Unavailable),
3824        };
3825        // Non-blocking view of the tool registry: if it is momentarily
3826        // write-locked, report honestly instead of blocking or guessing.
3827        let tool_count = match self.tools.try_read() {
3828            Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
3829            Err(_) => ReportValue::Unavailable,
3830        };
3831        trace::ContextReport {
3832            model: self.model.clone(),
3833            system_prompt_bytes: ReportValue::Count(
3834                self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
3835            ),
3836            tool_count,
3837            history_messages,
3838            history_bytes,
3839            loaded_skills: ReportValue::Unavailable,
3840            loaded_memories: ReportValue::Unavailable,
3841            cache: self.trace_ctx.cache_snapshots().last_activity(),
3842            trace_enabled: self.trace_ctx.enabled(),
3843            writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3844            degraded_records: self.trace_ctx.degraded_records(),
3845        }
3846    }
3847
3848    /// The shared observability writer, when telemetry is enabled.
3849    pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
3850        self.telemetry_writer.clone()
3851    }
3852
3853    /// Bounded shutdown flush of the observability writer: stop intake,
3854    /// drain until `timeout`, return typed stats (`None` when telemetry is
3855    /// off). Sync and bounded — safe from shutdown paths that cannot await;
3856    /// async callers should use [`Self::shutdown_observability_async`].
3857    ///
3858    /// Semantics (Task 11):
3859    /// - telemetry `off` → no writer exists → `None`, a true no-op;
3860    /// - "flushed" means every queued record was appended into OS file
3861    ///   buffers (`write(2)` returned) — deliberately no `fsync`, these are
3862    ///   best-effort diagnostic logs;
3863    /// - on timeout the worker stays detached and keeps draining in the
3864    ///   background; the caller logs metadata-only stats and continues —
3865    ///   trace loss must never fail or abort a clean exit.
3866    pub fn shutdown_observability(
3867        &self,
3868        timeout: std::time::Duration,
3869    ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3870        // Operational retention (Task 12): the exit epilogue also removes
3871        // expired content-capture bundles — bounded, fail-soft, so a stale
3872        // bundle never has to wait for the next trace interaction. Swept at
3873        // the CONSTRUCTION-BOUND root (fix1 I2b), immune to env churn.
3874        let _ = trace::sweep_expired_captures(&self.capture_dir);
3875        // Drain a retained one-shot ephemeral writer first (Task 12 fix:
3876        // an armed `/trace next` record written with telemetry Off must
3877        // survive session exit). Its outcome is returned only when no
3878        // session writer exists — the session writer's stats remain the
3879        // primary signal.
3880        let ephemeral = self
3881            .one_shot_trace_writer
3882            .lock()
3883            .expect("one-shot trace writer lock poisoned")
3884            .take();
3885        let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
3886        self.telemetry_writer
3887            .as_ref()
3888            .map(|w| w.shutdown(timeout))
3889            .or(ephemeral_outcome)
3890    }
3891
3892    /// Async epilogue helper for every clean process/runtime exit path that
3893    /// owns a `Runtime` (headless chat, TUI teardown, autonomous agent,
3894    /// RPC/server graceful shutdown): clones the writer handle and runs the
3895    /// bounded [`Self::shutdown_observability`] on the blocking pool, so an
3896    /// executor thread is never parked. Same `None`/no-fsync/timeout
3897    /// semantics as the sync variant. Idempotent — a second call finds the
3898    /// intake already closed and returns immediately.
3899    ///
3900    /// Note: the writer is shared across `Runtime` clones (subagents). Call
3901    /// this only from the session owner's exit epilogue, after in-flight
3902    /// work has drained — cloned runtimes still serving requests would see
3903    /// their subsequent records counted as drops.
3904    pub async fn shutdown_observability_async(
3905        &self,
3906        timeout: std::time::Duration,
3907    ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3908        // Same operational retention sweep as the sync epilogue, off the
3909        // executor (bounded filesystem work, fail-soft on a failed spawn).
3910        // Construction-bound root (fix1 I2b).
3911        let capture_dir = self.capture_dir.clone();
3912        let _ =
3913            tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
3914        // Same one-shot ephemeral drain as the sync variant.
3915        let ephemeral = self
3916            .one_shot_trace_writer
3917            .lock()
3918            .expect("one-shot trace writer lock poisoned")
3919            .take();
3920        let ephemeral_outcome = match ephemeral {
3921            Some(writer) => Some(writer.shutdown_async(timeout).await),
3922            None => None,
3923        };
3924        match self.telemetry_writer.clone() {
3925            Some(writer) => Some(writer.shutdown_async(timeout).await),
3926            None => ephemeral_outcome,
3927        }
3928    }
3929
3930    /// Test seam: install a custom writer (e.g. temp paths, artificial
3931    /// write delay) as the session observability sink, exactly as
3932    /// `sync_observability` would for a production writer.
3933    #[cfg(any(test, feature = "testing"))]
3934    pub fn install_observability_for_tests(
3935        &mut self,
3936        writer: crate::runtime::telemetry::TelemetryWriter,
3937    ) {
3938        self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3939            crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3940        ));
3941        self.telemetry_writer = Some(writer);
3942    }
3943
3944    pub fn cache_diagnostics(&self) -> bool {
3945        self.cache_diagnostics
3946    }
3947
3948    pub fn set_cache_diagnostics(&mut self, v: bool) {
3949        self.cache_diagnostics = v;
3950    }
3951
3952    pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
3953        self.cache_ttl
3954    }
3955
3956    /// Thread a parent runtime's TTL atomics onto this (cloned) runtime so
3957    /// that a driver-owned turn shares the session's single downgrade latch
3958    /// instead of firing a redundant 1h→5m notice.
3959    pub fn share_ttl_latches(&mut self, parent: &Runtime) {
3960        self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
3961        self.saw_1h_honored = parent.saw_1h_honored.clone();
3962        self.cache_anchor = parent.cache_anchor.clone();
3963        // Same session, same diagnostics chain: a driver turn continues it,
3964        // and a ping sees the same rejection latch (it never moves the chain).
3965        self.last_msg_id = parent.last_msg_id.clone();
3966        self.cache_diag_rejected = parent.cache_diag_rejected.clone();
3967    }
3968
3969    /// The cache-diagnosis chain for this runtime's requests, `None` when
3970    /// `cache_diagnostics` is off (wire unchanged).
3971    pub(crate) fn cache_diagnosis(&self) -> Option<cache_diagnosis::CacheDiagnosis> {
3972        self.cache_diagnostics.then(|| {
3973            cache_diagnosis::CacheDiagnosis::new(
3974                Arc::clone(&self.last_msg_id),
3975                Arc::clone(&self.cache_diag_rejected),
3976            )
3977        })
3978    }
3979
3980    /// Change the cache TTL strategy mid-session. The next request re-marks
3981    /// with the new TTL; the old prefix expires naturally (single-last
3982    /// strategy never prunes old markers, so no invalidation logic needed).
3983    pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
3984        self.cache_ttl = ttl;
3985    }
3986
3987    pub fn thinking_level(&self) -> &str {
3988        // For Max/Ultra the named_level wins; for legacy budget-only levels
3989        // we fall through to the legacy bucketing.
3990        match self.named_level {
3991            Some(level) => level.as_str(),
3992            None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3993        }
3994    }
3995
3996    /// Check if the OAuth token is expired and refresh it if needed.
3997    pub async fn refresh_if_needed(&self) -> Result<()> {
3998        self.refresh_if_needed_for_model(&self.model).await
3999    }
4000
4001    /// `/auth reload`: drop the in-memory Anthropic token and re-vend it
4002    /// through the credential broker (re-reads auth.json locally, forcing a
4003    /// refresh if the file still holds the token we had). Returns a
4004    /// token-free status line.
4005    pub async fn reload_auth(&self) -> Result<String> {
4006        AuthMethods::reload_after_rejection(
4007            Arc::clone(&self.auth),
4008            &self.client,
4009            &self.credential_source,
4010            &self.token_cache,
4011            None,
4012        )
4013        .await?;
4014        let expires = self.auth.read().await.token_expires;
4015        let source = if self.credential_source.is_remote() {
4016            "remote broker"
4017        } else {
4018            "auth.json"
4019        };
4020        Ok(match expires {
4021            Some(exp) => {
4022                let mins = exp.saturating_sub(crate::epoch_millis()) / 60_000;
4023                format!("Anthropic credentials reloaded from {source} (valid ~{mins} min)")
4024            }
4025            None => format!("Anthropic credentials reloaded from {source}"),
4026        })
4027    }
4028
4029    async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
4030        // Non-Anthropic models resolve their own provider auth in the OpenAI
4031        // path (incl. via the broker), so skip the Anthropic pre-fetch. (#158 #7)
4032        if !crate::runtime::auth::model_is_anthropic(model) {
4033            return Ok(());
4034        }
4035        AuthMethods::refresh_if_needed(
4036            Arc::clone(&self.auth),
4037            &self.client,
4038            &self.credential_source,
4039            &self.token_cache,
4040            Some(model),
4041            crate::runtime::auth::RefreshPoint::TurnStart,
4042        )
4043        .await
4044    }
4045
4046    /// Make a simple non-streaming API call for compaction (no tools).
4047    ///
4048    /// Uses a dedicated summarization system prompt (not the user's), omits
4049    /// all tools, and returns the raw text response. Caller supplies the
4050    /// full message array including the serialized conversation.
4051    pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
4052        // Transport-construction seam: counted BEFORE preflight, auth
4053        // refresh, or any request assembly (CP-12 M3).
4054        self.remote_summarization_attempts
4055            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4056        let model = self.compaction_model();
4057        self.validate_request_preflight_for(
4058            model,
4059            crate::runtime::openai::catalog::CodexRequestRole::Internal,
4060        )
4061        .await?;
4062        self.refresh_if_needed_for_model(model).await?;
4063
4064        use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
4065
4066        ApiMethods::call_api_simple(
4067            &self.auth,
4068            &self.client,
4069            model,
4070            COMPACTION_SYSTEM_PROMPT,
4071            self.thinking_budget,
4072            self.reasoning_level(),
4073            &messages,
4074            self.api_retries,
4075            // Default options preserve this path's historical beta gating
4076            // and endpoint; only the session observability seams are wired
4077            // (Task 11) so compaction requests trace/persist like any other.
4078            // Deliberately the BASE context: an internal compaction request
4079            // must not consume a user's one-shot `/trace next` arm.
4080            &api::ApiOptions {
4081                trace: self.trace_ctx.clone(),
4082                telemetry: self.telemetry_writer.clone(),
4083                ..api::ApiOptions::default()
4084            },
4085        )
4086        .await
4087    }
4088
4089    /// Run a single prompt synchronously (non-streaming). Handles tool execution
4090    /// internally, looping until the model produces a final text response.
4091    pub async fn run_single(&self, prompt: &str) -> Result<String> {
4092        if self
4093            .continuation
4094            .lock()
4095            .unwrap_or_else(std::sync::PoisonError::into_inner)
4096            .durability_blocked
4097        {
4098            return Err(RuntimeError::Session(
4099                "context head save is unresolved; reload the session before further inference"
4100                    .into(),
4101            ));
4102        }
4103        self.validate_request_preflight().await?;
4104        let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
4105        // Refresh OAuth token if expired only after capability preflight.
4106        self.refresh_if_needed().await?;
4107
4108        let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
4109            json!({"role": "user", "content": prompt}),
4110        )];
4111        let system_prompt = self.effective_system_prompt().await;
4112
4113        loop {
4114            let response = ApiMethods::call_api(
4115                &self.auth,
4116                &self.client,
4117                &self.model,
4118                &*self.tools.read().await,
4119                &system_prompt,
4120                self.thinking_budget,
4121                self.reasoning_level(),
4122                &messages,
4123                self.api_retries,
4124                &api::ApiOptions {
4125                    memory_backend: None,
4126                    use_1m_context: self.context_window_override == Some(1_000_000),
4127                    cache_ttl: self.cache_ttl,
4128                    ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
4129                    saw_1h_honored: self.saw_1h_honored.clone(),
4130                    credential_source: self.credential_source.clone(),
4131                    token_cache: self.token_cache.clone(),
4132                    anthropic_base_url: None,
4133                    anthropic_stream_client: self.anthropic_stream_client.clone(),
4134                    anthropic_execution_plan: anthropic_execution_plan.clone(),
4135                    codex_request_role: self.codex_request_role(),
4136                    // Consistent with the streaming path: a pending
4137                    // `/trace next` arm covers the first request of this
4138                    // loop too (the one-shot gate inside the armed context
4139                    // limits it to exactly one logical request).
4140                    trace: self.effective_trace_context(),
4141                    request_correlation: None,
4142                    suppress_stream_deltas: true,
4143                    telemetry: self.telemetry_writer.clone(),
4144                    // Non-stream path note (Task 16 review): threading the
4145                    // host session here gates extension-provider interior
4146                    // tool loops identically to stream turns. The built-in
4147                    // tool dispatch below (registry.get) remains the legacy
4148                    // non-stream path — tracked; the required pass gate is
4149                    // all STREAM-turn execution paths.
4150                    tool_session_id: Some(self.host_tool_session.clone()),
4151                    // Non-stream legacy loop: no retained per-stream set;
4152                    // the extension route falls back per its documented
4153                    // policy (fresh default-core, zero activations).
4154                    session_tool_set: None,
4155                    request_tools_schema: None,
4156                    anthropic_tool_split: None,
4157                    usage_counters: None,
4158                    cache_anchor: None,
4159                    keepwarm: None,
4160                    // The non-stream transport never sends diagnostics.
4161                    cache_diagnosis: None,
4162                },
4163            )
4164            .await?;
4165
4166            // Check if Claude wants to use tools
4167            if let Some(content) = response["content"].as_array() {
4168                let mut response_text = String::new();
4169                let mut tool_uses = Vec::new();
4170
4171                // Process response content
4172                for item in content {
4173                    match item["type"].as_str() {
4174                        Some("text") => {
4175                            if let Some(text) = item["text"].as_str() {
4176                                response_text.push_str(text);
4177                            }
4178                        }
4179                        Some("tool_use") => {
4180                            tool_uses.push(item.clone());
4181                        }
4182                        _ => {}
4183                    }
4184                }
4185
4186                // If no tool uses, return the text response
4187                if tool_uses.is_empty() {
4188                    if let Some(orchestration) = &self.orchestration {
4189                        if let agent_core::orchestration::CompletionGate::Blocked { workers } =
4190                            orchestration.completion_gate()
4191                        {
4192                            return Err(crate::RuntimeError::Tool(format!(
4193                                "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
4194                                workers.len(),
4195                                workers.join(", ")
4196                            )));
4197                        }
4198                    }
4199                    return Ok(response_text);
4200                }
4201
4202                // Add assistant's response to conversation (only content, role)
4203                messages.push(std::sync::Arc::new(json!({
4204                    "role": "assistant",
4205                    "content": content
4206                })));
4207
4208                // Execute tools — parallel when multiple are requested
4209                let mut tool_results = Vec::new();
4210
4211                if tool_uses.len() == 1 {
4212                    // Single tool — run inline, no spawn overhead
4213                    let tool_use = &tool_uses[0];
4214                    if let (Some(tool_name), Some(tool_id)) =
4215                        (tool_use["name"].as_str(), tool_use["id"].as_str())
4216                    {
4217                        let input = &tool_use["input"];
4218                        let (result, rich_blocks) = match self.tools.read().await.get(tool_name).cloned() {
4219                            Some(tool) => {
4220                                let input = self
4221                                    .tools
4222                                    .read()
4223                                    .await
4224                                    .translate_input_for_api_tool(tool_name, input.clone());
4225                                let runtime_name = self
4226                                    .tools
4227                                    .read()
4228                                    .await
4229                                    .runtime_name_for_api(tool_name)
4230                                    .to_string();
4231                                let ctx = crate::ToolContext {
4232                                    channels: crate::tools::ToolChannels {
4233                                        tx_delta: None,
4234                                        tx_events: None,
4235                                    },
4236                                    capabilities: crate::tools::ToolCapabilities {
4237                                        session_allow_all: Some(self.session_allow_all.clone()),
4238                                        launch_cancel: None,
4239                                        memory_backend: Some(self.memory_backend.clone()),
4240                                        telemetry: Some(self.worker_telemetry()),
4241                                        watcher_exit_path: self.watcher_exit_path.clone(),
4242                                        tool_register_tx: None,
4243                                        session_manager: Some(self.session_manager.clone()),
4244                                        subagent_registry: Some(self.subagent_registry.clone()),
4245                                        delegation_parent: None,
4246                                        codex_parent_plan: Self::codex_delegation_plan(
4247                                            &self.model,
4248                                            self.reasoning_level(),
4249                                            self.codex_request_role(),
4250                                        ),
4251                                        event_queue: Some(self.event_queue.clone()),
4252                                        secret_prompt: None,
4253                                        orchestration: self.orchestration.clone(),
4254                                        tool_activation: None,
4255                                        mcp_leases: None,
4256                                        extension_leases: None,
4257                                        memory_context: self.memory_tool_capability(),
4258                                        cwd: self.cwd.clone(),
4259                                        env: self.env.clone(),
4260                                        env_stripped: self.env_stripped.clone(),
4261                                        env_warned: self.env_warned.clone(),
4262                                        session_id: self.session_id.clone(),
4263                                    },
4264                                    limits: crate::tools::ToolLimits {
4265                                        max_tool_output: self.max_tool_output,
4266                                        max_tool_buffer: 256 * 1024,
4267                                        bash_timeout: self.bash_timeout,
4268                                        bash_max_timeout: self.bash_max_timeout,
4269                                        subagent_timeout: self.subagent_timeout,
4270                                    },
4271                                };
4272                                let decision = resolve_before_tool_call_decision(
4273                                    input.clone(),
4274                                    emit_before_tool_call(
4275                                        &self.hook_bus,
4276                                        tool_name,
4277                                        Some(&runtime_name),
4278                                        input.clone(),
4279                                        self.session_id.as_deref(),
4280                                    )
4281                                    .await,
4282                                    None,
4283                                    false,
4284                                    Some(&self.session_allow_all),
4285                                )
4286                                .await;
4287                                if let BeforeToolCallDecision::Block { reason } = decision {
4288                                    (format!("Tool call blocked by extension: {}", reason), None)
4289                                } else {
4290                                    let BeforeToolCallDecision::Continue { input } = decision
4291                                    else {
4292                                        unreachable!()
4293                                    };
4294                                    let input_for_hook = input.clone();
4295                                    let (output, rich_blocks) = match tool.execute_rich(input, ctx).await {
4296                                        Ok(output) => validated_single_tool_output(&self.model, output),
4297                                        Err(e) => (e.to_string(), None),
4298                                    };
4299                                    let hooked_output = emit_after_tool_call(
4300                                        &self.hook_bus,
4301                                        tool_name,
4302                                        Some(&runtime_name),
4303                                        input_for_hook,
4304                                        output.clone(),
4305                                        self.max_tool_output,
4306                                        self.session_id.as_deref(),
4307                                    )
4308                                    .await;
4309                                    let rich_blocks = retain_single_tool_blocks(rich_blocks, &output, &hooked_output);
4310                                    (hooked_output, rich_blocks)
4311                                }
4312                            }
4313                            None => (format!("Unknown tool: {}", tool_name), None),
4314                        };
4315                        tool_results.push(json!({
4316                            "type": "tool_result",
4317                            "tool_use_id": tool_id,
4318                            "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
4319                        }));
4320                    }
4321                } else {
4322                    // Multiple tools — run in parallel with JoinSet
4323                    let mut join_set = tokio::task::JoinSet::new();
4324
4325                    // Capture config values before spawning (can't borrow &self in 'static spawn)
4326                    let cfg_max_tool_output = self.max_tool_output;
4327                    let cfg_bash_timeout = self.bash_timeout;
4328                    let cfg_bash_max_timeout = self.bash_max_timeout;
4329                    let cfg_subagent_timeout = self.subagent_timeout;
4330                    let session_mgr = self.session_manager.clone();
4331                    let cfg_subagent_registry = self.subagent_registry.clone();
4332                    let cfg_event_queue = self.event_queue.clone();
4333                    let cfg_hook_bus = self.hook_bus.clone();
4334                    let cfg_orchestration = self.orchestration.clone();
4335                    let codex_parent_plan = Self::codex_delegation_plan(
4336                        &self.model,
4337                        self.reasoning_level(),
4338                        self.codex_request_role(),
4339                    );
4340                    let cfg_cwd = self.cwd.clone();
4341                    let cfg_env = self.env.clone();
4342                    let cfg_session_id = self.session_id.clone();
4343
4344                    for tool_use in &tool_uses {
4345                        if let (Some(tool_name), Some(tool_id)) = (
4346                            tool_use["name"].as_str().map(|s| s.to_string()),
4347                            tool_use["id"].as_str().map(|s| s.to_string()),
4348                        ) {
4349                            let input = tool_use["input"].clone();
4350                            let tools_snapshot = self.tools.read().await;
4351                            let input =
4352                                tools_snapshot.translate_input_for_api_tool(&tool_name, input);
4353                            let runtime_name =
4354                                tools_snapshot.runtime_name_for_api(&tool_name).to_string();
4355                            let tool = tools_snapshot.get(&tool_name).cloned();
4356                            drop(tools_snapshot);
4357                            let exit_path = self.watcher_exit_path.clone();
4358                            let session_mgr_inner = session_mgr.clone();
4359                            let registry_inner = cfg_subagent_registry.clone();
4360                            let event_queue_inner = cfg_event_queue.clone();
4361                            let hook_bus_inner = cfg_hook_bus.clone();
4362                            let session_allow_all_inner = self.session_allow_all.clone();
4363                            let orchestration_inner = cfg_orchestration.clone();
4364                            let memory_backend_inner = self.memory_backend.clone();
4365                            let worker_telemetry_inner = self.worker_telemetry();
4366                            let memory_context_inner = self.memory_tool_capability();
4367                            let codex_parent_plan_inner = codex_parent_plan.clone();
4368                            let cwd_inner = cfg_cwd.clone();
4369                            let env_inner = cfg_env.clone();
4370                            let env_stripped_inner = self.env_stripped.clone();
4371                            let env_warned_inner = self.env_warned.clone();
4372                            let session_id_inner = cfg_session_id.clone();
4373                            let attachment_model = self.model.clone();
4374                            let tool_name_for_hook = tool_name.clone();
4375                            let runtime_name_for_hook = runtime_name.clone();
4376
4377                            join_set.spawn(async move {
4378                                let result = match tool {
4379                                    Some(t) => {
4380                                        let decision =
4381                                            crate::runtime::resolve_before_tool_call_decision(
4382                                                input.clone(),
4383                                                crate::runtime::emit_before_tool_call(
4384                                                    &hook_bus_inner,
4385                                                    &tool_name_for_hook,
4386                                                    Some(&runtime_name_for_hook),
4387                                                    input.clone(),
4388                                                    session_id_inner.as_deref(),
4389                                                )
4390                                                .await,
4391                                                None,
4392                                                false,
4393                                                Some(&session_allow_all_inner),
4394                                            )
4395                                            .await;
4396                                        if let crate::runtime::BeforeToolCallDecision::Block {
4397                                            reason,
4398                                        } = decision
4399                                        {
4400                                            (format!("Tool call blocked by extension: {}", reason), None)
4401                                        } else {
4402                                            let crate::runtime::BeforeToolCallDecision::Continue {
4403                                                input,
4404                                            } = decision
4405                                            else {
4406                                                unreachable!()
4407                                            };
4408                                            let ctx = crate::ToolContext {
4409                                                channels: crate::tools::ToolChannels {
4410                                                    tx_delta: None,
4411                                                    tx_events: None,
4412                                                },
4413                                                capabilities: crate::tools::ToolCapabilities {
4414                                                    session_allow_all: Some(session_allow_all_inner.clone()),
4415                                                    launch_cancel: None,
4416                                                    memory_backend: Some(memory_backend_inner),
4417                                                    telemetry: Some(worker_telemetry_inner),
4418                                                    watcher_exit_path: exit_path,
4419                                                    tool_register_tx: None,
4420                                                    session_manager: Some(session_mgr_inner),
4421                                                    subagent_registry: Some(registry_inner),
4422                                                    delegation_parent: None,
4423                                                    codex_parent_plan: codex_parent_plan_inner,
4424                                                    event_queue: Some(event_queue_inner),
4425                                                    secret_prompt: None,
4426                                                    orchestration: orchestration_inner,
4427                                                    tool_activation: None,
4428                                                    mcp_leases: None,
4429                                                    extension_leases: None,
4430                                                    memory_context: memory_context_inner,
4431                                                    cwd: cwd_inner,
4432                                                    env: env_inner,
4433                                                    env_stripped: env_stripped_inner,
4434                                                    env_warned: env_warned_inner,
4435                                                    session_id: session_id_inner.clone(),
4436                                                },
4437                                                limits: crate::tools::ToolLimits {
4438                                                    max_tool_output: cfg_max_tool_output,
4439                                                    max_tool_buffer: 256 * 1024,
4440                                                    bash_timeout: cfg_bash_timeout,
4441                                                    bash_max_timeout: cfg_bash_max_timeout,
4442                                                    subagent_timeout: cfg_subagent_timeout,
4443                                                },
4444                                            };
4445                                            let input_for_hook = input.clone();
4446                                            let (output, rich_blocks) =
4447                                                match t.execute_rich(input, ctx).await {
4448                                                    Ok(output) => validated_single_tool_output(
4449                                                        &attachment_model,
4450                                                        output,
4451                                                    ),
4452                                                    Err(e) => (e.to_string(), None),
4453                                                };
4454                                            let hooked_output =
4455                                                crate::runtime::emit_after_tool_call(
4456                                                    &hook_bus_inner,
4457                                                    &tool_name_for_hook,
4458                                                    Some(&runtime_name_for_hook),
4459                                                    input_for_hook,
4460                                                    output.clone(),
4461                                                    cfg_max_tool_output,
4462                                                    session_id_inner.as_deref(),
4463                                                )
4464                                                .await;
4465                                            let rich_blocks = retain_single_tool_blocks(
4466                                                rich_blocks,
4467                                                &output,
4468                                                &hooked_output,
4469                                            );
4470                                            (hooked_output, rich_blocks)
4471                                        }
4472                                    }
4473                                    None => (format!("Unknown tool: {}", tool_name), None),
4474                                };
4475                                (tool_id, result)
4476                            });
4477                        }
4478                    }
4479
4480                    // Collect results, preserving order by tool_id
4481                    let mut results_map = std::collections::HashMap::new();
4482                    while let Some(res) = join_set.join_next().await {
4483                        match res {
4484                            Ok((tool_id, result)) => {
4485                                results_map.insert(tool_id, result);
4486                            }
4487                            Err(e) => {
4488                                // Task panicked — log it but don't crash
4489                                tracing::error!("Parallel tool task panicked: {}", e);
4490                            }
4491                        }
4492                    }
4493
4494                    // Build tool_results in original order — every tool_use MUST have a result
4495                    for tool_use in &tool_uses {
4496                        if let Some(tool_id) = tool_use["id"].as_str() {
4497                            let (result, rich_blocks) = results_map.remove(tool_id).unwrap_or_else(|| {
4498                                ("Tool execution failed: task panicked".to_string(), None)
4499                            });
4500                            tool_results.push(json!({
4501                                "type": "tool_result",
4502                                "tool_use_id": tool_id,
4503                                "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
4504                            }));
4505                        }
4506                    }
4507                }
4508
4509                // Add tool results to conversation
4510                messages.push(std::sync::Arc::new(json!({
4511                    "role": "user",
4512                    "content": tool_results
4513                })));
4514
4515                // Continue the loop to get Claude's response with tool results
4516            } else {
4517                return Err(RuntimeError::Tool("Invalid response format".to_string()));
4518            }
4519        }
4520    }
4521
4522    /// Run a prompt as a cancellable stream of [`StreamEvent`]s. Convenience wrapper
4523    /// around [`run_stream_with_messages`] for single-turn usage.
4524    pub async fn run_stream(
4525        &self,
4526        prompt: String,
4527        cancel: CancellationToken,
4528    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4529        self.run_stream_with_messages(
4530            vec![std::sync::Arc::new(
4531                json!({"role": "user", "content": prompt}),
4532            )],
4533            cancel,
4534            None,
4535            None,
4536            false,
4537        )
4538        .await
4539    }
4540
4541    /// Session-scoped "Allow all" latch for extension Confirm gates: the
4542    /// extension ids the user picked "Allow all from <ext>" for.
4543    pub fn session_allow_all(&self) -> &std::sync::Arc<crate::tools::SessionConfirmLatch> {
4544        &self.session_allow_all
4545    }
4546
4547    /// Bind a worker to the calling session, not the process-wide host.
4548    pub(crate) fn inherit_session_allow_all(
4549        &mut self,
4550        parent: Option<&std::sync::Arc<crate::tools::SessionConfirmLatch>>,
4551    ) {
4552        if let Some(parent) = parent {
4553            self.session_allow_all = parent.clone();
4554        }
4555    }
4556
4557    /// Run a multi-turn conversation as a cancellable stream of [`StreamEvent`]s.
4558    /// This is the main entry point for chat UIs and agents. Handles tool execution,
4559    /// API retries, and dynamic tool registration (MCP) internally.
4560    pub async fn run_stream_with_messages(
4561        &self,
4562        messages: Vec<crate::SharedMessage>,
4563        cancel: CancellationToken,
4564        steering_rx: Option<mpsc::UnboundedReceiver<String>>,
4565        secret_prompt: Option<crate::tools::SecretPromptHandle>,
4566        auto_approve_confirms: bool,
4567    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4568        self.run_stream_tracked(
4569            messages,
4570            cancel,
4571            steering_rx,
4572            secret_prompt,
4573            auto_approve_confirms,
4574            TurnCompletion::new(),
4575        )
4576        .await
4577    }
4578
4579    /// [`Runtime::run_stream_with_messages`] that also reports, through
4580    /// `completion`, whether the turn reached its normal end (see
4581    /// [`TurnCompletion`]).
4582    pub async fn run_stream_tracked(
4583        &self,
4584        messages: Vec<crate::SharedMessage>,
4585        cancel: CancellationToken,
4586        steering_rx: Option<mpsc::UnboundedReceiver<String>>,
4587        secret_prompt: Option<crate::tools::SecretPromptHandle>,
4588        auto_approve_confirms: bool,
4589        completion: TurnCompletion,
4590    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4591        // CP-11 fix-2 (A): the caller-facing boundary is BOUNDED. The
4592        // internal producer keeps an unbounded sender for API stability;
4593        // the relay drains it eagerly, enforces the fixed preview-delta
4594        // retention budget, and cancels the turn when the caller stream
4595        // is dropped (releasing provider tasks).
4596        let (tx, internal_rx) = mpsc::unbounded_channel();
4597        let bounded_rx =
4598            crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
4599
4600        // One correlation ID per turn: carried by the typed terminal outcome
4601        // (spec §5.2) so every frontend can tie the failure to trace lines.
4602        let turn_correlation_id = agent_core::next_turn_correlation_id();
4603
4604        if self
4605            .continuation
4606            .lock()
4607            .unwrap_or_else(std::sync::PoisonError::into_inner)
4608            .durability_blocked
4609        {
4610            let error = RuntimeError::Session(
4611                "context head save is unresolved; reload the session before further inference"
4612                    .into(),
4613            );
4614            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4615                helpers::turn_error_for(&error, &turn_correlation_id),
4616            )));
4617            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4618            return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4619        }
4620
4621        if let Err(error) = self.validate_request_preflight().await {
4622            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4623                helpers::turn_error_for(&error, &turn_correlation_id),
4624            )));
4625            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4626            return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4627        }
4628
4629        let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
4630            Ok(plan) => plan,
4631            Err(error) => {
4632                let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4633                    helpers::turn_error_for(&error, &turn_correlation_id),
4634                )));
4635                let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4636                return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4637            }
4638        };
4639
4640        // Refresh OAuth token if expired after capability preflight.
4641        if let Err(e) = self.refresh_if_needed().await {
4642            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4643                helpers::turn_error_for(&e, &turn_correlation_id),
4644            )));
4645            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4646            return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4647        }
4648
4649        // Task B4 (spec §7.4): resolve this turn's memory recall HERE — the
4650        // single provider-agnostic point where the owned turn Vec exists but
4651        // has not yet been read, borrowed, or forked into any per-provider
4652        // wire translation (`run_stream_internal` and everything below it
4653        // only ever borrows from this Vec). When no contribution applies the
4654        // Vec is returned byte-identical — no empty or no-op synthetic
4655        // message is ever inserted — and the disabled path performs zero
4656        // extension calls. Every recall failure fails OPEN: the turn itself
4657        // is never blocked or failed by memory.
4658        let capture_started_at = std::time::SystemTime::now();
4659        let capture_turn_start = terminal_capture_start(&messages);
4660        // Snapshot the full prompt-time lease: terminal dispatch must never
4661        // reselect a provider after the user changes memory state.
4662        let capture_lease = self
4663            .memory_context_lock()
4664            .capture_lease_at(capture_started_at);
4665        let mut messages = messages;
4666        self.apply_turn_memory_recall(&mut messages).await;
4667        let messages = messages;
4668
4669        // Clone the Arc, not the whole Runtime — the spawned task shares the
4670        // same AuthState so mid-loop token refreshes are visible immediately.
4671        let auth = Arc::clone(&self.auth);
4672        let client = self.client.clone();
4673        let credential_source = self.credential_source.clone();
4674        let token_cache = self.token_cache.clone();
4675        let model = self.model.clone();
4676        let tools = self.tools.clone();
4677        let system_prompt = self.effective_system_prompt().await;
4678        let thinking_budget = self.thinking_budget;
4679        let reasoning_level = self.reasoning_level();
4680        let watcher_exit_path = self.watcher_exit_path.clone();
4681        let max_tool_output = self.max_tool_output;
4682        let bash_timeout = self.bash_timeout;
4683        let bash_max_timeout = self.bash_max_timeout;
4684        let subagent_timeout = self.subagent_timeout;
4685        let api_retries = self.api_retries;
4686        let refusal_retries = self.refusal_retries;
4687        let session_manager = self.session_manager.clone();
4688        // Opt into the 1M-context beta header only when the user explicitly
4689        // requested 1M (via context_window setting). Default 200k matches
4690        // Anthropic's claude-code default and gives smarter inference.
4691        let subagent_registry = self.subagent_registry.clone();
4692        // Extra Arc clone for the reaper hook — session takes ownership of the
4693        // original clone above; this one is captured separately by the spawn closure.
4694        let reaper_registry = Arc::clone(&subagent_registry);
4695        let reaper_orchestration = self.orchestration.clone();
4696        let capture_provider = capture_lease
4697            .as_ref()
4698            .and_then(|lease| self.extension_capture_provider(lease));
4699        let capture_state = self.memory_context_state.clone();
4700        let event_queue = self.event_queue.clone();
4701        let options = api::ApiOptions {
4702            memory_backend: None,
4703            use_1m_context: self.context_window_override == Some(1_000_000),
4704            cache_ttl: self.cache_ttl,
4705            ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
4706            saw_1h_honored: self.saw_1h_honored.clone(),
4707            credential_source: self.credential_source.clone(),
4708            token_cache: self.token_cache.clone(),
4709            anthropic_base_url: None,
4710            anthropic_stream_client: self.anthropic_stream_client.clone(),
4711            anthropic_execution_plan,
4712            codex_request_role: self.codex_request_role(),
4713            trace: self.effective_trace_context(),
4714            request_correlation: None,
4715            suppress_stream_deltas: false,
4716            telemetry: self.telemetry_writer.clone(),
4717            // Threads the runtime-scoped gate identity into extension-
4718            // provider interior tool loops (Task 16).
4719            tool_session_id: Some(self.host_tool_session.clone()),
4720            // Placeholder: the stream loop installs its RETAINED shared
4721            // session tool set handle and request schema projection before
4722            // the first provider round.
4723            session_tool_set: None,
4724            request_tools_schema: None,
4725            anthropic_tool_split: None,
4726            usage_counters: None,
4727            // The foreground turn's accepted requests are what keep-warm
4728            // schedules from.
4729            cache_anchor: Some(self.cache_anchor.clone()),
4730            keepwarm: None,
4731            // The foreground chain: the only requests that thread
4732            // `diagnostics.previous_message_id`.
4733            cache_diagnosis: self.cache_diagnosis(),
4734        };
4735
4736        let final_capture_history: Arc<Mutex<Option<Vec<crate::SharedMessage>>>> = Arc::new(Mutex::new(None));
4737        let session = crate::runtime::stream::StreamSession {
4738            memory_backend: self.memory_backend.clone(),
4739            memory_context: self.memory_tool_capability(),
4740            final_capture_history: final_capture_history.clone(),
4741            turn_completion: completion,
4742            context_window: self.context_window(),
4743            continuation: self.continuation.clone(),
4744            auth,
4745            client,
4746            credential_source,
4747            token_cache,
4748            options,
4749            api_retries,
4750            refusal_retries,
4751            model,
4752            tools,
4753            system_prompt,
4754            thinking_budget,
4755            reasoning_level,
4756            tx: tx.clone(),
4757            cancel,
4758            steering_rx,
4759            watcher_exit_path,
4760            max_tool_output,
4761            bash_timeout,
4762            bash_max_timeout,
4763            subagent_timeout,
4764            session_manager,
4765            subagent_registry,
4766            event_queue,
4767            secret_prompt,
4768            hook_bus: self.hook_bus.clone(),
4769            session_id: self.session_id.clone(),
4770            cwd: self.cwd.clone(),
4771            env: self.env.clone(),
4772            env_stripped: self.env_stripped.clone(),
4773            env_warned: self.env_warned.clone(),
4774            auto_approve_confirms,
4775            session_allow_all: self.session_allow_all.clone(),
4776            telemetry_level: self.telemetry_level,
4777            orchestration: self.orchestration.clone(),
4778            delegation_parent: self.delegation_parent.clone(),
4779            turn_correlation_id: turn_correlation_id.clone(),
4780            progressive_tool_disclosure: self.progressive_tool_disclosure,
4781            deferred_tool_loading: self.deferred_tool_loading,
4782            activation_confirm: self.activation_confirm,
4783            tool_session_id: self.host_tool_session.clone(),
4784            retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4785            pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
4786            mcp_runtime: self.mcp_runtime.clone(),
4787            mcp_session_scope: self.mcp_session_scope.clone(),
4788            extension_runtime: self.extension_runtime.clone(),
4789            extension_session_scope: self.extension_session_scope.clone(),
4790            turn_budget: self.turn_budget.clone(),
4791            owner_steer_budget: self.owner_steer_budget.clone(),
4792        };
4793
4794        tokio::spawn(async move {
4795            let completed = match StreamMethods::run_stream_internal(session, messages).await {
4796                Ok(()) => true,
4797                Err(e) => {
4798                    let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4799                        helpers::turn_error_for(&e, &turn_correlation_id),
4800                    )));
4801                    false
4802                }
4803            };
4804            // The stream publishes its FINAL history only on a valid terminal
4805            // completion; capture that, never the pre-inference prompt.
4806            dispatch_completed_terminal_capture(
4807                completed,
4808                &final_capture_history,
4809                &capture_state,
4810                capture_lease.zip(capture_provider),
4811                capture_started_at,
4812                capture_turn_start.as_ref(),
4813            );
4814            // Engine-owned housekeeping: reap finished subagent handles before
4815            // signalling Done.  Runs on the tokio thread pool — no public sync
4816            // caller becomes async.  Poison-safe via reap_finished internals.
4817            crate::runtime::subagent::reap_finished(
4818                &reaper_registry,
4819                reaper_orchestration.as_deref(),
4820            );
4821            agent_core::core::memstat::log_turn_memory();
4822            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4823        });
4824
4825        Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
4826    }
4827}
4828
4829impl Clone for Runtime {
4830    fn clone(&self) -> Self {
4831        Self {
4832            client: self.client.clone(),
4833            anthropic_stream_client: self.anthropic_stream_client.clone(),
4834            auth: Arc::clone(&self.auth),
4835            model: self.model.clone(),
4836            tools: self.tools.clone(),
4837            system_prompt: self.system_prompt.clone(),
4838            effective_prompt: self.effective_prompt.clone(),
4839            prompt_generation: self.prompt_generation,
4840            prompt_reload_source: self.prompt_reload_source.clone(),
4841            thinking_budget: self.thinking_budget,
4842            named_level: self.named_level,
4843            explicit_reasoning: self.explicit_reasoning,
4844            codex_request_role: self.codex_request_role,
4845            context_window_override: self.context_window_override,
4846            continuation: Arc::clone(&self.continuation),
4847            compaction_model: self.compaction_model.clone(),
4848            compaction_mode: self.compaction_mode,
4849            compaction_exclusions: self.compaction_exclusions.clone(),
4850            remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
4851            subagent_registry: self.subagent_registry.clone(),
4852            orchestration: self.orchestration.clone(),
4853            event_queue: self.event_queue.clone(),
4854            watcher_exit_path: self.watcher_exit_path.clone(),
4855            max_tool_output: self.max_tool_output,
4856            bash_timeout: self.bash_timeout,
4857            bash_max_timeout: self.bash_max_timeout,
4858            subagent_timeout: self.subagent_timeout,
4859            api_retries: self.api_retries,
4860            refusal_retries: self.refusal_retries,
4861            telemetry_level: self.telemetry_level,
4862            // Shared session observability: clones (subagents) enqueue into
4863            // the SAME bounded writer and trace context — one worker and one
4864            // set of session-scoped context IDs per session.
4865            telemetry_writer: self.telemetry_writer.clone(),
4866            trace_ctx: self.trace_ctx.clone(),
4867            trace_controls: Arc::clone(&self.trace_controls),
4868            // Clones are the SAME session: `/memory` state is one truth
4869            // across a session's streams. Subagents are NOT clones — they
4870            // are constructed via a fresh `Runtime::new()` (see
4871            // `tools/subagent/mod.rs::apply_subagent_runtime_policy`), so
4872            // they always start Off/no-lease (task A5 invariant).
4873            memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
4874            memory_backend: self.memory_backend.clone(),
4875            memory_backend_config: self.memory_backend_config.clone(),
4876            memory_backend_reconfigure_denied: Arc::clone(&self.memory_backend_reconfigure_denied),
4877            pending_history_import_preview: std::sync::Arc::clone(
4878                &self.pending_history_import_preview,
4879            ),
4880            history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
4881            #[cfg(any(test, feature = "testing"))]
4882            capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
4883            // Clones are the same session: they observe the same held
4884            // recall segment (one truth), like memory_context_state above.
4885            pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
4886            // Same-session truth for retry-exact reuse and `/memory why`
4887            // (task B4): clones share ONE retained recall per request.
4888            retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
4889            one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
4890            capture_dir: self.capture_dir.clone(),
4891            cache_diagnostics: self.cache_diagnostics,
4892            cache_ttl: self.cache_ttl,
4893            // Subagents are their own session — fresh latches so a downgrade
4894            // in the subagent's chain surfaces its own (single) notice and
4895            // honored-state isn't inherited from the parent.
4896            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4897            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4898            cache_anchor: Default::default(),
4899            // Subagents start their own request chain — inheriting the parent's
4900            // last msg_id would produce bogus `messages_changed` diagnostics.
4901            last_msg_id: Arc::new(Mutex::new(None)),
4902            cache_diag_rejected: Arc::clone(&self.cache_diag_rejected),
4903            session_manager: self.session_manager.clone(),
4904            hook_bus: self.hook_bus.clone(),
4905            reaper_handle: None, // Cloned runtimes don't own the reaper
4906            reaper_cancel: None, // Cloned runtimes don't own the reaper
4907            credential_source: self.credential_source.clone(),
4908            token_cache: self.token_cache.clone(), // shares the same cache (Arc inside)
4909            trusted_worker_models: self.trusted_worker_models.clone(),
4910            subagent_limits: self.subagent_limits,
4911            progressive_tool_disclosure: self.progressive_tool_disclosure,
4912            deferred_tool_loading: self.deferred_tool_loading,
4913            activation_confirm: self.activation_confirm,
4914            delegation_parent: self.delegation_parent.clone(),
4915            session_allow_all: self.session_allow_all.clone(),
4916            mcp_runtime: self.mcp_runtime.clone(),
4917            // Clones SHARE the durable session scope: dropping one clone or
4918            // one stream can never kill a sibling's leases.
4919            mcp_session_scope: self.mcp_session_scope.clone(),
4920            extension_runtime: self.extension_runtime.clone(),
4921            // Same durable shared-scope rule for extension leases.
4922            extension_session_scope: self.extension_session_scope.clone(),
4923            turn_budget: self.turn_budget.clone(),
4924            owner_steer_budget: self.owner_steer_budget.clone(),
4925            // Clones share the live tool registry, so they share the SAME
4926            // host tool session (matching existing shared-session behavior);
4927            // independently constructed runtimes mint fresh identities and
4928            // can never share session grants.
4929            host_tool_session: self.host_tool_session.clone(),
4930            tool_catalog_prompt: Arc::clone(&self.tool_catalog_prompt),
4931            retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4932            pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
4933            // Clones serve the same conversation (see memory_context_state).
4934            session_id: self.session_id.clone(),
4935            cwd: self.cwd.clone(),
4936            env: self.env.clone(),
4937            env_stripped: self.env_stripped.clone(),
4938            env_warned: self.env_warned.clone(),
4939        }
4940    }
4941}
4942
4943#[cfg(test)]
4944mod rich_output_validation_tests {
4945    use super::*;
4946
4947    const MODEL: &str = "anthropic/claude-sonnet-4-6";
4948
4949    fn rich_output(summary: &str) -> crate::ToolOutput {
4950        crate::ToolOutput::Blocks {
4951            summary: summary.to_string(),
4952            // Deliberately omit leading text to exercise into_parts normalization.
4953            blocks: vec![json!({
4954                "type": "image",
4955                "source": {
4956                    "type": "base64",
4957                    "media_type": "image/png",
4958                    "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l+QAAAAASUVORK5CYII="
4959                }
4960            })],
4961        }
4962    }
4963
4964    #[test]
4965    fn unchanged_summary_retains_rich_array_without_text_truncation() {
4966        let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image"));
4967        assert_eq!(summary, "image");
4968        let expected = blocks.clone().expect("supported image");
4969        assert_eq!(expected[0], json!({"type": "text", "text": summary}));
4970        assert_eq!(expected[1]["type"], "image");
4971        let retained = retain_single_tool_blocks(blocks, &summary, &summary);
4972        assert_eq!(
4973            single_tool_result_content(&summary, retained, summary.len()),
4974            Value::Array(expected)
4975        );
4976    }
4977
4978    #[test]
4979    fn rewritten_or_truncated_summary_drops_rich_blocks() {
4980        let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image summary"));
4981        assert!(blocks.is_some());
4982        for hooked in [
4983            "redacted".to_string(),
4984            String::new(),
4985            helpers::HelperMethods::truncate_tool_result(&summary, 5),
4986        ] {
4987            let retained = retain_single_tool_blocks(blocks.clone(), &summary, &hooked);
4988            assert!(retained.is_none());
4989            assert_eq!(
4990                single_tool_result_content(&hooked, retained, 5),
4991                Value::String(helpers::HelperMethods::truncate_tool_result(&hooked, 5))
4992            );
4993        }
4994    }
4995
4996    #[test]
4997    fn unsupported_or_malformed_media_becomes_explicit_text() {
4998        let mut malformed = rich_output("private summary");
4999        if let crate::ToolOutput::Blocks { blocks, .. } = &mut malformed {
5000            blocks[0]["source"]["data"] = json!("private invalid payload");
5001        }
5002        for (model, output) in [
5003            (
5004                "google-gemini/unsupported-rich-test",
5005                rich_output("private summary"),
5006            ),
5007            (MODEL, malformed),
5008        ] {
5009            let (summary, blocks) = validated_single_tool_output(model, output);
5010            assert!(summary.starts_with("Attachment not sent: "), "{summary}");
5011            assert!(!summary.contains("private"));
5012            assert!(blocks.is_none());
5013            assert_eq!(
5014                single_tool_result_content(&summary, blocks, 1024),
5015                Value::String(summary)
5016            );
5017        }
5018    }
5019
5020    #[test]
5021    fn plain_text_and_errors_keep_legacy_truncation() {
5022        for text in ["plain output", "tool error", "Unknown tool: missing"] {
5023            let (summary, blocks) = validated_single_tool_output(
5024                "google-gemini/unsupported-rich-test",
5025                crate::ToolOutput::Text(text.to_string()),
5026            );
5027            assert_eq!(summary, text);
5028            assert!(blocks.is_none());
5029            assert_eq!(
5030                single_tool_result_content(&summary, blocks, 5),
5031                Value::String(helpers::HelperMethods::truncate_tool_result(text, 5))
5032            );
5033        }
5034    }
5035}
5036
5037#[cfg(test)]
5038mod tests {
5039    use super::*;
5040
5041    /// Task 16 session identity semantics: clones of one Runtime share the
5042    /// live tool registry, so they share the same host tool session;
5043    /// independently constructed runtimes mint fresh identities and can
5044    /// never share session grants.
5045    #[test]
5046    fn host_tool_session_shared_by_clones_fresh_per_runtime() {
5047        let rt = Runtime::new_headless();
5048        let clone = rt.clone();
5049        assert_eq!(
5050            rt.host_tool_session_id(),
5051            clone.host_tool_session_id(),
5052            "clones share the live registry and therefore the host session"
5053        );
5054
5055        let other = Runtime::new_headless();
5056        assert_ne!(
5057            rt.host_tool_session_id(),
5058            other.host_tool_session_id(),
5059            "independently constructed runtimes must never share a session"
5060        );
5061    }
5062
5063    /// Activations handed to a runtime before its first turn are what it
5064    /// hands on (a second reload, or a park, before any turn loses
5065    /// nothing); clones share them; another runtime does not see them.
5066    #[test]
5067    fn handed_activations_are_carried_on_before_the_first_turn() {
5068        use crate::tools::activation::{CarriedActivation, CarriedProvenance};
5069        let probe = |name: &str| CarriedActivation {
5070            tool_id: format!("builtin:{name}"),
5071            schema_digest: "ab".repeat(32),
5072            provenance: CarriedProvenance::BuiltinRuntime,
5073        };
5074        let rt = Runtime::new_headless();
5075        assert!(rt.carried_tool_activations().is_empty());
5076        rt.restore_tool_activations(vec![probe("subagent_start")]);
5077        rt.restore_tool_activations(Vec::new());
5078        rt.restore_tool_activations(vec![probe("subagent_start"), probe("subagent_status")]);
5079        assert_eq!(
5080            rt.carried_tool_activations(),
5081            vec![probe("subagent_start"), probe("subagent_status")],
5082            "merged without duplicates; an empty hand-off clears nothing"
5083        );
5084        assert_eq!(rt.clone().carried_tool_activations().len(), 2);
5085        assert!(Runtime::new_headless().carried_tool_activations().is_empty());
5086    }
5087
5088    /// Task 11 config rule (documented until Task 12 adds explicit trace
5089    /// config): telemetry `basic`/`full` enables the shared session writer
5090    /// sink — legacy telemetry AND metadata-only trace persistence; `off`
5091    /// disables both (trace context reverts to the no-op sink).
5092    #[test]
5093    fn telemetry_level_gates_shared_observability_sink() {
5094        let mut rt = Runtime::new_headless();
5095        assert!(!rt.trace_context().enabled(), "off by default");
5096        assert!(rt.telemetry_writer().is_none());
5097
5098        rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
5099        assert!(rt.trace_context().enabled(), "basic enables the trace sink");
5100        assert!(rt.telemetry_writer().is_some());
5101
5102        // Same level again: the writer/session context is not recreated.
5103        let ctx_before = format!("{:?}", rt.trace_context());
5104        rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
5105        assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
5106
5107        // Clones (subagents) share the same writer sink + session context.
5108        let clone = rt.clone();
5109        assert!(clone.trace_context().enabled());
5110        assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
5111        assert!(clone.telemetry_writer().is_some());
5112
5113        rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
5114        assert!(!rt.trace_context().enabled(), "off disables both");
5115        assert!(rt.telemetry_writer().is_none());
5116        assert!(rt
5117            .shutdown_observability(std::time::Duration::ZERO)
5118            .is_none());
5119    }
5120
5121    /// Owner-epilogue contract (Task 11): with telemetry Off the async
5122    /// flush is a `None` no-op and idempotent; with a writer installed, a
5123    /// chat-like epilogue (final record queued, then bounded flush) must
5124    /// persist the record; and a slow writer must return within the budget
5125    /// (TimedOut) instead of blocking exit.
5126    #[tokio::test]
5127    async fn shutdown_observability_async_epilogue_contract() {
5128        // Off → None, twice (idempotent no-op, no writer ever created).
5129        let rt = Runtime::new_headless();
5130        let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
5131        assert!(rt.shutdown_observability_async(budget).await.is_none());
5132        assert!(rt.shutdown_observability_async(budget).await.is_none());
5133
5134        // Chat-like shutdown: the final queued record is persisted.
5135        let tmp = tempfile::tempdir().unwrap();
5136        let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
5137        let mut rt = Runtime::new_headless();
5138        let writer = crate::runtime::telemetry::TelemetryWriter::new(
5139            crate::runtime::telemetry::WriterOptions {
5140                telemetry_path: Some(telemetry_path.clone()),
5141                trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
5142                ..Default::default()
5143            },
5144        );
5145        rt.install_observability_for_tests(writer.clone());
5146        writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
5147            ts: 7,
5148            model: "claude-sonnet-4-6".to_string(),
5149            ..Default::default()
5150        });
5151        let outcome = rt
5152            .shutdown_observability_async(budget)
5153            .await
5154            .expect("writer installed");
5155        assert!(outcome.is_flushed());
5156        assert_eq!(outcome.stats().written, 1);
5157        assert_eq!(
5158            std::fs::read_to_string(&telemetry_path)
5159                .unwrap()
5160                .lines()
5161                .count(),
5162            1,
5163            "queued final record must be on disk after the epilogue flush"
5164        );
5165        // Idempotent second call: intake already closed, still Flushed.
5166        assert!(rt
5167            .shutdown_observability_async(budget)
5168            .await
5169            .expect("writer still installed")
5170            .is_flushed());
5171
5172        // Slow writer: the epilogue returns within its bounded budget.
5173        let mut rt = Runtime::new_headless();
5174        let slow = crate::runtime::telemetry::TelemetryWriter::new(
5175            crate::runtime::telemetry::WriterOptions {
5176                telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
5177                trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
5178                write_delay: Some(std::time::Duration::from_millis(300)),
5179                ..Default::default()
5180            },
5181        );
5182        rt.install_observability_for_tests(slow.clone());
5183        for _ in 0..10 {
5184            slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
5185        }
5186        let start = std::time::Instant::now();
5187        let outcome = rt
5188            .shutdown_observability_async(std::time::Duration::from_millis(200))
5189            .await
5190            .expect("writer installed");
5191        assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
5192        // Generous ceiling (no timing fragility): well under the 3s a full
5193        // drain would need, proving the deadline was honored.
5194        assert!(
5195            start.elapsed() < std::time::Duration::from_millis(1500),
5196            "bounded epilogue must not block exit: {:?}",
5197            start.elapsed()
5198        );
5199    }
5200
5201    /// Task 12 fix: an armed one-shot ephemeral writer (telemetry Off +
5202    /// `/trace next`) is retained by the runtime and drained by the same
5203    /// shutdown epilogue as the session writer, so the armed record cannot
5204    /// be lost to an exit racing the background worker.
5205    #[tokio::test]
5206    async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
5207        let rt = Runtime::new_headless();
5208        assert!(!rt.trace_context().enabled(), "telemetry off");
5209        rt.trace_arm_next(false);
5210        let armed = rt.effective_trace_context();
5211        assert!(armed.enabled(), "armed ephemeral context traces");
5212        // No session writer, but the epilogue still finds (and drains) the
5213        // retained one-shot writer.
5214        let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
5215        let outcome = rt
5216            .shutdown_observability_async(budget)
5217            .await
5218            .expect("retained one-shot writer must be drained by the epilogue");
5219        assert!(outcome.is_flushed());
5220        // Consumed: a second epilogue call finds nothing.
5221        assert!(rt.shutdown_observability_async(budget).await.is_none());
5222    }
5223
5224    /// Task 12 operational expiry: stale content-capture bundles are
5225    /// physically removed at Runtime startup and by BOTH shutdown
5226    /// epilogues (sync and async), not only on trace interactions.
5227    /// Combined into one serialized test: all three paths resolve the
5228    /// capture dir through `SYNAPS_BASE_DIR`.
5229    #[tokio::test]
5230    #[serial_test::serial(synaps_base_dir)]
5231    async fn startup_and_shutdown_paths_sweep_expired_captures() {
5232        struct BaseDirGuard(Option<String>);
5233        impl Drop for BaseDirGuard {
5234            fn drop(&mut self) {
5235                match self.0.take() {
5236                    Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
5237                    None => std::env::remove_var("SYNAPS_BASE_DIR"),
5238                }
5239            }
5240        }
5241        let tmp = tempfile::tempdir().unwrap();
5242        let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
5243        std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
5244
5245        let cap_dir = trace::default_capture_dir();
5246        assert!(
5247            cap_dir.starts_with(tmp.path()),
5248            "capture dir must be private to the test"
5249        );
5250        let stale_id = trace::TraceId::new("req-stale").unwrap();
5251        let plant_stale = || {
5252            agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
5253            let stale = trace::controls::ContentCaptureBundle {
5254                schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
5255                request_id: stale_id.clone(),
5256                created_unix_ms: 1_000,
5257                expires_unix_ms: 2_000, // long past
5258                redacted: true,
5259                over_budget: false,
5260                body: Some(serde_json::json!({"old": true})),
5261            };
5262            let path = trace::controls::capture_path(&cap_dir, &stale_id);
5263            std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
5264            path
5265        };
5266
5267        // 1) Runtime startup sweeps.
5268        let stale_path = plant_stale();
5269        let rt = Runtime::new().await.expect("runtime constructs");
5270        assert!(
5271            !stale_path.exists(),
5272            "Runtime startup must physically remove expired capture bundles"
5273        );
5274
5275        // 2) Sync shutdown epilogue sweeps (telemetry off → None outcome).
5276        let stale_path = plant_stale();
5277        let _ = rt.shutdown_observability(std::time::Duration::ZERO);
5278        assert!(
5279            !stale_path.exists(),
5280            "sync shutdown epilogue must remove expired capture bundles"
5281        );
5282
5283        // 3) Async shutdown epilogue sweeps.
5284        let stale_path = plant_stale();
5285        let _ = rt
5286            .shutdown_observability_async(std::time::Duration::from_millis(100))
5287            .await;
5288        assert!(
5289            !stale_path.exists(),
5290            "async shutdown epilogue must remove expired capture bundles"
5291        );
5292        drop(guard);
5293    }
5294
5295    /// fix1 I2b: the capture root binds at CONSTRUCTION. Ambient
5296    /// SYNAPS_BASE_DIR churn after construction must not redirect the
5297    /// sweep — each runtime keeps sweeping its own root (concurrent
5298    /// roots), so a parallel test flipping env can never race the epilogue.
5299    #[tokio::test]
5300    #[serial_test::serial(synaps_base_dir)]
5301    async fn capture_root_binds_at_construction_and_survives_env_churn() {
5302        let base_a = tempfile::tempdir().unwrap();
5303        let base_b = tempfile::tempdir().unwrap();
5304        let old = std::env::var("SYNAPS_BASE_DIR").ok();
5305
5306        std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
5307        let rt_a = Runtime::new().await.expect("runtime A");
5308        std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
5309        let rt_b = Runtime::new().await.expect("runtime B");
5310
5311        let cap_a = base_a.path().join("trace").join("capture");
5312        let cap_b = base_b.path().join("trace").join("capture");
5313        let plant = |dir: &std::path::Path, name: &str| {
5314            agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
5315            let id = trace::TraceId::new(name).unwrap();
5316            let stale = trace::controls::ContentCaptureBundle {
5317                schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
5318                request_id: id.clone(),
5319                created_unix_ms: 1_000,
5320                expires_unix_ms: 2_000,
5321                redacted: true,
5322                over_budget: false,
5323                body: Some(serde_json::json!({"old": true})),
5324            };
5325            let path = trace::controls::capture_path(dir, &id);
5326            std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
5327            path
5328        };
5329
5330        // Point ambient env somewhere else entirely — the bound roots must win.
5331        let decoy = tempfile::tempdir().unwrap();
5332        std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
5333
5334        let stale_a = plant(&cap_a, "req-stale-a");
5335        let stale_b = plant(&cap_b, "req-stale-b");
5336        let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
5337        assert!(
5338            !stale_a.exists(),
5339            "runtime A must sweep the root bound at ITS construction"
5340        );
5341        assert!(
5342            stale_b.exists(),
5343            "runtime A must never sweep another runtime's root"
5344        );
5345        let _ = rt_b
5346            .shutdown_observability_async(std::time::Duration::from_millis(100))
5347            .await;
5348        assert!(
5349            !stale_b.exists(),
5350            "runtime B must sweep the root bound at ITS construction"
5351        );
5352
5353        match old {
5354            Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
5355            None => std::env::remove_var("SYNAPS_BASE_DIR"),
5356        }
5357    }
5358
5359    #[test]
5360    fn reload_rejects_policy_change_without_partial_apply() {
5361        let dir = tempfile::tempdir().unwrap();
5362        let path = dir.path().join("prompt.yaml");
5363        let manifest = |total: &str, content: &str| {
5364            format!(
5365            "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"
5366        )
5367        };
5368        std::fs::write(&path, manifest("1", "before")).unwrap();
5369        let parsed =
5370            agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
5371                .unwrap();
5372        let model =
5373            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
5374        let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
5375        let stack = agent_core::prompt::compile_prompt_stack(
5376            &parsed,
5377            &parsed.registry(path.parent()).unwrap(),
5378            &context,
5379            None,
5380        )
5381        .unwrap();
5382        let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
5383            &model,
5384            parsed.delegation_catalog_candidates(),
5385        )
5386        .unwrap();
5387        let digest = parsed
5388            .delegation_policy(model, &catalog)
5389            .unwrap()
5390            .map(|p| p.digest());
5391        let mut runtime = Runtime::new_headless();
5392        runtime.apply_prompt_stack(stack).unwrap();
5393        runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
5394        let generation = runtime.prompt_generation();
5395        let composed = runtime.effective_prompt().unwrap().composed().to_owned();
5396
5397        std::fs::write(&path, manifest("2", "after")).unwrap();
5398        let error = runtime.reload_prompt().unwrap_err().to_string();
5399        assert!(error.contains("cannot safely change delegation policy"));
5400        assert_eq!(runtime.prompt_generation(), generation);
5401        assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
5402        assert_eq!(
5403            runtime
5404                .prompt_reload_source
5405                .as_ref()
5406                .unwrap()
5407                .delegation_policy_digest,
5408            digest
5409        );
5410    }
5411
5412    #[test]
5413    fn worker_install_does_not_replay_process_global_favorite_grants() {
5414        let foreground =
5415            crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
5416                .unwrap();
5417        let session = Arc::new(
5418            crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
5419        );
5420        let favorite = "openai-codex/gpt-5.6-sol";
5421        assert!(
5422            session.preflight(favorite).is_err(),
5423            "precondition: the parent session did not grant this identity"
5424        );
5425
5426        let mut worker = Runtime::new_headless();
5427        worker.apply_config(&crate::config::SynapsConfig {
5428            favorite_models: vec![favorite.to_owned()],
5429            ..Default::default()
5430        });
5431        worker.install_worker_orchestration(Arc::clone(&session));
5432
5433        assert!(
5434            session.preflight(favorite).is_err(),
5435            "constructing a child must not mint/replay a favorite-model grant"
5436        );
5437        assert!(!session.effective_choices().contains(&favorite.to_owned()));
5438    }
5439
5440    #[test]
5441    fn host_install_may_apply_explicit_operator_favorites() {
5442        let mut runtime = Runtime::new_headless();
5443        let config = crate::config::SynapsConfig {
5444            favorite_models: vec![
5445                "openai-codex/gpt-5.6-luna".to_owned(),
5446                "anthropic/claude-opus-4-6".to_owned(),
5447                // Legacy favorite spelling must retain its existing compatibility.
5448                "claude/claude-fable-5".to_owned(),
5449                // Malformed persisted values must fail closed without bricking boot.
5450                "not-qualified".to_owned(),
5451            ],
5452            ..Default::default()
5453        };
5454        runtime.apply_config(&config);
5455        let foreground =
5456            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
5457        runtime.install_orchestration(std::sync::Arc::new(
5458            crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
5459        ));
5460
5461        let orchestration = runtime.orchestration().unwrap();
5462        for trusted in [
5463            "openai-codex/gpt-5.6-sol",
5464            "openai-codex/gpt-5.6-luna",
5465            "anthropic/claude-opus-4-6",
5466            "anthropic/claude-fable-5",
5467        ] {
5468            assert!(
5469                orchestration
5470                    .effective_choices()
5471                    .contains(&trusted.to_owned()),
5472                "missing configured worker choice {trusted}: {:?}",
5473                orchestration.effective_choices()
5474            );
5475        }
5476        let authorized = orchestration
5477            .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
5478            .expect("an explicitly favorited cross-provider model must be authorized");
5479        assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
5480        assert!(!orchestration
5481            .effective_choices()
5482            .contains(&"not-qualified".to_owned()));
5483    }
5484
5485    #[test]
5486    fn configured_worker_choices_survive_manifestless_foreground_changes() {
5487        let mut runtime = Runtime::new_headless();
5488        runtime.apply_config(&crate::config::SynapsConfig {
5489            favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
5490            ..Default::default()
5491        });
5492        let foreground =
5493            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
5494        runtime.install_orchestration(std::sync::Arc::new(
5495            crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
5496        ));
5497
5498        runtime
5499            .try_set_model("xai-auth/grok-4.5-latest".to_owned())
5500            .unwrap();
5501
5502        let orchestration = runtime.orchestration().unwrap();
5503        assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
5504        assert!(orchestration
5505            .effective_choices()
5506            .contains(&"anthropic/claude-opus-4-6".to_owned()));
5507        orchestration
5508            .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
5509            .expect("configured worker trust must survive policy replacement");
5510    }
5511
5512    #[test]
5513    fn configured_subagent_limits_size_the_policy_rebuilt_on_model_change() {
5514        let mut runtime = Runtime::new_headless();
5515        assert_eq!(
5516            runtime.subagent_limits(),
5517            crate::config::SubagentLimits::default(),
5518            "defaults before any config"
5519        );
5520        runtime.apply_config(&crate::config::SynapsConfig {
5521            subagent: crate::config::SubagentLimits {
5522                max_concurrent: 24,
5523                max_total: 96,
5524                max_depth: 2,
5525            },
5526            ..Default::default()
5527        });
5528        let foreground =
5529            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
5530        runtime.install_orchestration(std::sync::Arc::new(
5531            crate::orchestration::OrchestrationRuntime::from_limits(
5532                foreground,
5533                runtime.subagent_limits(),
5534            )
5535            .unwrap(),
5536        ));
5537        runtime
5538            .try_set_model("xai-auth/grok-4.5-latest".to_owned())
5539            .unwrap();
5540
5541        let orchestration = runtime.orchestration().unwrap();
5542        assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
5543        assert_eq!(
5544            orchestration.ultracode_readiness("xai-auth/grok-4.5-latest"),
5545            Ok((24, 96)),
5546            "the replacement policy uses the configured limits, not a hardcoded 8/64"
5547        );
5548        assert_eq!(orchestration.reserve_delegation("a", None), Ok(1));
5549        assert_eq!(orchestration.reserve_delegation("b", Some("a")), Ok(2));
5550        assert_eq!(
5551            orchestration.reserve_delegation("c", Some("b")),
5552            Err(crate::orchestration::DelegationTreeDenied::DepthLimit { max_depth: 2 })
5553        );
5554    }
5555
5556    #[test]
5557    fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
5558        let mut runtime = Runtime::new_headless();
5559        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5560        runtime.install_orchestration(std::sync::Arc::new(
5561            crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
5562        ));
5563
5564        runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
5565
5566        let orchestration = runtime.orchestration().unwrap();
5567        assert_eq!(
5568            orchestration.foreground_model(),
5569            "openrouter/deepseek/deepseek-v4-pro"
5570        );
5571        assert!(orchestration
5572            .effective_choices()
5573            .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
5574        assert!(orchestration
5575            .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
5576            .is_ok());
5577    }
5578
5579    /// Regression ("model change blocked: trusted worker catalog
5580    /// unavailable"): a daemon-hosted session has no live Muse catalog in
5581    /// its process (the picker fetched it in the TUI process). Switching to
5582    /// a Muse id must not depend on that process-local cache.
5583    #[test]
5584    fn set_model_to_uncached_muse_id_rebuilds_orchestration() {
5585        let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
5586        crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
5587        let mut runtime = Runtime::new_headless();
5588        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5589        runtime.install_orchestration(std::sync::Arc::new(
5590            crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
5591        ));
5592
5593        for model in ["muse/muse-wonder-1.0", "muse/muse-live-only-zz5"] {
5594            runtime
5595                .try_set_model(model.to_owned())
5596                .unwrap_or_else(|e| panic!("{model}: {e}"));
5597            assert_eq!(runtime.model(), model);
5598            assert_eq!(runtime.orchestration().unwrap().foreground_model(), model);
5599        }
5600        // Malformed ids still fail closed.
5601        assert!(runtime.try_set_model("muse/Bad Id".to_owned()).is_err());
5602        assert_eq!(runtime.model(), "muse/muse-live-only-zz5");
5603    }
5604
5605    #[test]
5606    fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
5607        let mut runtime = Runtime::new_headless();
5608        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5609        let orchestration = std::sync::Arc::new(
5610            crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
5611        );
5612        orchestration
5613            .resolve_and_authorize("sa_active", None)
5614            .unwrap();
5615        runtime.install_orchestration(orchestration.clone());
5616        runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
5617
5618        let error = runtime
5619            .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
5620            .unwrap_err();
5621
5622        assert!(error.contains("collection/reconciliation"));
5623        assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
5624        assert!(std::sync::Arc::ptr_eq(
5625            runtime.orchestration().unwrap(),
5626            &orchestration
5627        ));
5628        assert!(orchestration.is_unreconciled("sa_active"));
5629    }
5630
5631    #[test]
5632    fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
5633        let dir = tempfile::tempdir().unwrap();
5634        let manifest_path = dir.path().join("prompt.yaml");
5635        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5636        let mut runtime = Runtime::new_headless();
5637        let orchestration = std::sync::Arc::new(
5638            crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
5639        );
5640        runtime.install_orchestration(orchestration.clone());
5641        runtime.retain_prompt_reload_source(
5642            manifest_path,
5643            agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
5644            None,
5645            None,
5646        );
5647
5648        runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
5649
5650        assert!(std::sync::Arc::ptr_eq(
5651            runtime.orchestration().unwrap(),
5652            &orchestration
5653        ));
5654        assert_eq!(
5655            runtime.orchestration().unwrap().foreground_model(),
5656            "openrouter/z-ai/glm-5.2"
5657        );
5658    }
5659
5660    #[test]
5661    fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
5662        let cases = [
5663            (
5664                "github-copilot/claude-opus-4.8",
5665                "github-copilot/claude-opus-4.8",
5666            ),
5667            ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
5668            ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
5669            ("openai/gpt-5", "openai/gpt-5"),
5670            ("claude-opus-4-8", "claude-opus-4-8"),
5671        ];
5672
5673        for (input, expected) in cases {
5674            let mut runtime = Runtime::new_headless();
5675            runtime.set_model(input.to_owned());
5676            assert_eq!(runtime.model(), expected, "input: {input}");
5677        }
5678    }
5679
5680    #[test]
5681    fn set_model_strips_only_legacy_health_status_decoration() {
5682        let cases = [
5683            ("✅  339ms  groq/llama-3.3-70b", "groq/llama-3.3-70b"),
5684            (
5685                "✅  339ms  github-copilot/claude-opus-4.8",
5686                "github-copilot/claude-opus-4.8",
5687            ),
5688            (
5689                "⚠️  1200ms  anthropic/claude-sonnet-4-6",
5690                "anthropic/claude-sonnet-4-6",
5691            ),
5692        ];
5693
5694        for (input, expected) in cases {
5695            let mut runtime = Runtime::new_headless();
5696            runtime.set_model(input.to_owned());
5697            assert_eq!(runtime.model(), expected, "input: {input}");
5698        }
5699    }
5700
5701    #[tokio::test]
5702    async fn confirm_without_prompt_fails_closed() {
5703        let result = resolve_before_tool_call_result(
5704            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
5705            None,
5706            false,
5707            None,
5708        )
5709        .await;
5710
5711        assert!(matches!(
5712            result,
5713            crate::extensions::hooks::events::HookResult::Block { reason }
5714                if reason.contains("requires confirmation") && reason.contains("Run deploy?")
5715        ));
5716    }
5717
5718    #[tokio::test]
5719    async fn modify_result_replaces_tool_input() {
5720        let result = resolve_before_tool_call_decision(
5721            serde_json::json!({"command":"rm -rf /"}),
5722            crate::extensions::hooks::events::HookResult::Modify {
5723                input: serde_json::json!({"command":"echo safe"}),
5724            },
5725            None,
5726            false,
5727            None,
5728        )
5729        .await;
5730
5731        match result {
5732            BeforeToolCallDecision::Continue { input } => {
5733                assert_eq!(input, serde_json::json!({"command":"echo safe"}));
5734            }
5735            BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
5736        }
5737    }
5738
5739    #[tokio::test]
5740    async fn confirm_prompt_yes_continues() {
5741        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5742        let handle = crate::tools::SecretPromptHandle::new(tx);
5743
5744        let task = tokio::spawn(async move {
5745            let request = rx.recv().await.expect("confirm prompt request");
5746            assert_eq!(request.title, "Confirm tool call");
5747            assert!(request.prompt.contains("Run deploy?"));
5748            let _ = request.response_tx.send(Some("yes".to_string()));
5749        });
5750
5751        let result = resolve_before_tool_call_result(
5752            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
5753            Some(&handle),
5754            false,
5755            None,
5756        )
5757        .await;
5758
5759        task.await.unwrap();
5760        assert!(matches!(
5761            result,
5762            crate::extensions::hooks::events::HookResult::Continue
5763        ));
5764    }
5765
5766    #[tokio::test]
5767    async fn confirm_prompt_non_yes_blocks() {
5768        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5769        let handle = crate::tools::SecretPromptHandle::new(tx);
5770
5771        let task = tokio::spawn(async move {
5772            let request = rx.recv().await.expect("confirm prompt request");
5773            let _ = request.response_tx.send(Some("no".to_string()));
5774        });
5775
5776        let result = resolve_before_tool_call_result(
5777            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
5778            Some(&handle),
5779            false,
5780            None,
5781        )
5782        .await;
5783
5784        task.await.unwrap();
5785        assert!(matches!(
5786            result,
5787            crate::extensions::hooks::events::HookResult::Block { reason }
5788                if reason.contains("confirmation denied")
5789        ));
5790    }
5791
5792    fn ext(ids: &[&str]) -> Vec<String> {
5793        ids.iter().map(|s| s.to_string()).collect()
5794    }
5795
5796    fn key(ids: &[&str]) -> Vec<crate::tools::LatchKey> {
5797        ids.iter().map(|s| (s.to_string(), 0)).collect()
5798    }
5799
5800    fn sourced_confirm(
5801        message: &str,
5802        source: &str,
5803        tool: &str,
5804    ) -> crate::extensions::hooks::events::HookResult {
5805        crate::extensions::hooks::events::HookResult::Confirm {
5806            message: message.into(),
5807            sources: ext(&[source]),
5808            tool: Some(tool.into()),
5809            generations: vec![],
5810            modified_input: None,
5811            modified_by: vec![],
5812        }
5813    }
5814
5815    #[test]
5816    fn session_allow_all_clones_share_but_independent_runtimes_do_not() {
5817        use std::sync::Arc;
5818        let parent = Runtime::new_headless();
5819        let cloned = parent.clone();
5820        let independent = Runtime::new_headless();
5821        assert!(Arc::ptr_eq(
5822            parent.session_allow_all(),
5823            cloned.session_allow_all()
5824        ));
5825        assert!(!Arc::ptr_eq(
5826            parent.session_allow_all(),
5827            independent.session_allow_all()
5828        ));
5829        cloned.session_allow_all().allow(&key(&["jev"]));
5830        assert!(parent.session_allow_all().allows_all(&key(&["jev"])));
5831        assert!(!independent.session_allow_all().allows_all(&key(&["jev"])));
5832    }
5833
5834    #[test]
5835    fn session_allow_all_conversation_boundary_isolates_old_clones() {
5836        use std::sync::Arc;
5837        let mut runtime = Runtime::new_headless();
5838        let old_worker = runtime.clone();
5839        old_worker.session_allow_all().allow(&key(&["jev"]));
5840
5841        runtime.begin_conversation(Some("next".into()));
5842        assert_eq!(runtime.session_id(), Some("next"));
5843        assert!(runtime.session_allow_all().is_empty());
5844        assert!(!Arc::ptr_eq(
5845            runtime.session_allow_all(),
5846            old_worker.session_allow_all()
5847        ));
5848        // #148 I2b: workers still running from the old conversation stop
5849        // auto-approving — the old set is revoked before the swap.
5850        assert!(
5851            old_worker.session_allow_all().is_empty(),
5852            "old latch must be cleared, not just replaced"
5853        );
5854        // An old clone can neither grant the new conversation nor re-arm
5855        // the revoked (orphaned) set.
5856        assert!(!old_worker.session_allow_all().allow(&key(&["jev"])));
5857        assert!(old_worker.session_allow_all().is_empty());
5858        assert!(runtime.session_allow_all().is_empty());
5859
5860        let new_worker = runtime.clone();
5861        new_worker.session_allow_all().allow(&key(&["jev"]));
5862        assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
5863        // Explicit resume of even the same ID starts fresh consent.
5864        runtime.begin_conversation(Some("next".into()));
5865        assert!(runtime.session_allow_all().is_empty());
5866        assert!(!Arc::ptr_eq(
5867            runtime.session_allow_all(),
5868            new_worker.session_allow_all()
5869        ));
5870    }
5871
5872    #[test]
5873    fn conversation_boundary_resets_the_cache_diagnosis_chain() {
5874        let mut runtime = Runtime::new_headless();
5875        runtime.set_cache_diagnostics(true);
5876        let mut driver = runtime.clone();
5877        driver.share_ttl_latches(&runtime);
5878        runtime.cache_diagnosis().unwrap().record("msg_old");
5879        // Compaction successor: same conversation, chain kept.
5880        runtime.set_session_id(Some("successor".into()));
5881        assert_eq!(
5882            runtime
5883                .cache_diagnosis()
5884                .unwrap()
5885                .previous_message_id()
5886                .as_deref(),
5887            Some("msg_old")
5888        );
5889        // `/clear` (and resume) start a new chain, seen by shared clones too.
5890        runtime.begin_conversation(Some("next".into()));
5891        assert_eq!(
5892            runtime.cache_diagnosis().unwrap().previous_message_id(),
5893            None
5894        );
5895        assert_eq!(
5896            driver.cache_diagnosis().unwrap().previous_message_id(),
5897            None
5898        );
5899    }
5900
5901    #[test]
5902    fn session_allow_all_identity_change_preserves_compaction_consent() {
5903        use std::sync::Arc;
5904        let mut runtime = Runtime::new_headless();
5905        let worker = runtime.clone();
5906        worker.session_allow_all().allow(&key(&["jev"]));
5907        runtime.set_session_id(Some("linked-successor".into()));
5908        assert!(Arc::ptr_eq(
5909            runtime.session_allow_all(),
5910            worker.session_allow_all()
5911        ));
5912        assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
5913    }
5914
5915    /// "Allow all from <ext>": the answer continues THIS call and latches
5916    /// that extension only. A later confirm from the same extension resolves
5917    /// without a prompt; a confirm from another extension still prompts.
5918    #[tokio::test]
5919    async fn confirm_prompt_always_latches_only_the_source_extension() {
5920        let latch = crate::tools::SessionConfirmLatch::new();
5921        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5922        let handle = crate::tools::SecretPromptHandle::new(tx);
5923
5924        let task = tokio::spawn(async move {
5925            let first = rx.recv().await.expect("first confirm prompt");
5926            // The dialog is told who asks and for which tool BEFORE answering.
5927            assert_eq!(
5928                first.confirm_scope,
5929                Some(crate::session::ConfirmScope {
5930                    extensions: vec!["jev".into()],
5931                    tool: Some("bash".into()),
5932                    rewritten_by: vec![],
5933                })
5934            );
5935            assert!(first.prompt.starts_with("Extension: jev\nTool: bash\n"), "{}", first.prompt);
5936            let _ = first
5937                .response_tx
5938                .send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
5939            // The next prompt must be the OTHER extension's confirm (jev's
5940            // second confirm is latched and never prompts).
5941            let other = rx.recv().await.expect("other extension still prompts");
5942            assert_eq!(
5943                other.confirm_scope.as_ref().map(|s| s.extensions.clone()),
5944                Some(vec!["other".to_string()])
5945            );
5946            let _ = other.response_tx.send(None);
5947            assert!(rx.recv().await.is_none(), "no further prompts");
5948        });
5949
5950        let first = resolve_before_tool_call_result(
5951            sourced_confirm("Run deploy?", "jev", "bash"),
5952            Some(&handle),
5953            false,
5954            Some(&latch),
5955        )
5956        .await;
5957        assert!(matches!(
5958            first,
5959            crate::extensions::hooks::events::HookResult::Continue
5960        ));
5961        assert_eq!(latch.extensions(), ext(&["jev"]), "latch keyed by source");
5962
5963        let second = resolve_before_tool_call_result(
5964            sourced_confirm("Run deploy again?", "jev", "write"),
5965            Some(&handle),
5966            false,
5967            Some(&latch),
5968        )
5969        .await;
5970        assert!(matches!(
5971            second,
5972            crate::extensions::hooks::events::HookResult::Continue
5973        ));
5974
5975        let other = resolve_before_tool_call_result(
5976            sourced_confirm("Run ls?", "other", "bash"),
5977            Some(&handle),
5978            false,
5979            Some(&latch),
5980        )
5981        .await;
5982        assert!(
5983            matches!(other, crate::extensions::hooks::events::HookResult::Block { .. }),
5984            "another extension's confirm is not covered by jev's latch"
5985        );
5986        drop(handle);
5987        task.await.unwrap();
5988    }
5989
5990    /// Latched A + unlatched B on one call (a merged Confirm): it prompts,
5991    /// the prompt names both, and "Allow all" then latches both.
5992    #[tokio::test]
5993    async fn latched_plus_unlatched_source_prompts_and_allow_all_latches_both() {
5994        let latch = crate::tools::SessionConfirmLatch::new();
5995        latch.allow(&key(&["a"]));
5996        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5997        let handle = crate::tools::SecretPromptHandle::new(tx);
5998        let task = tokio::spawn(async move {
5999            let req = rx.recv().await.expect("must prompt: b is not latched");
6000            assert_eq!(
6001                req.confirm_scope.as_ref().map(|s| s.extensions.clone()),
6002                Some(vec!["a".to_string(), "b".to_string()])
6003            );
6004            assert!(req.prompt.starts_with("Extension: a, b\n"), "{}", req.prompt);
6005            let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
6006        });
6007        let merged = || crate::extensions::hooks::events::HookResult::Confirm {
6008            message: "[a] x\n\n[b] y".into(),
6009            sources: ext(&["a", "b"]),
6010            generations: vec![],
6011            tool: Some("bash".into()),
6012            modified_input: None,
6013            modified_by: vec![],
6014        };
6015        let first = resolve_before_tool_call_result(merged(), Some(&handle), false, Some(&latch)).await;
6016        task.await.unwrap();
6017        assert!(matches!(first, crate::extensions::hooks::events::HookResult::Continue));
6018        assert!(latch.allows_all(&key(&["a", "b"])));
6019        // Now fully latched: no prompt (handle dropped → would block).
6020        drop(handle);
6021        let second = resolve_before_tool_call_result(merged(), None, false, Some(&latch)).await;
6022        assert!(matches!(second, crate::extensions::hooks::events::HookResult::Continue));
6023    }
6024
6025    /// A call site with no latch never offers "Allow all" (no scope), so the
6026    /// dialog and the session's "armed" notice can't promise it.
6027    #[tokio::test]
6028    async fn no_latch_means_no_allow_all_scope() {
6029        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6030        let handle = crate::tools::SecretPromptHandle::new(tx);
6031        let task = tokio::spawn(async move {
6032            let req = rx.recv().await.unwrap();
6033            assert!(req.confirm_scope.is_none());
6034            let _ = req.response_tx.send(Some("y".into()));
6035        });
6036        let r = resolve_before_tool_call_result(
6037            sourced_confirm("Run?", "jev", "bash"),
6038            Some(&handle),
6039            false,
6040            None,
6041        )
6042        .await;
6043        task.await.unwrap();
6044        assert!(matches!(r, crate::extensions::hooks::events::HookResult::Continue));
6045    }
6046
6047    /// prA N3: a Confirm whose input an earlier extension rewrote shows who
6048    /// rewrote it and a capped preview of the FINAL input, ahead of the
6049    /// confirmer's own message; the scope records `rewritten_by` so the
6050    /// session can mark the transcript; approval runs that final input.
6051    #[tokio::test]
6052    async fn rewritten_input_is_shown_in_the_confirm_prompt() {
6053        let latch = crate::tools::SessionConfirmLatch::new();
6054        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6055        let handle = crate::tools::SecretPromptHandle::new(tx);
6056        let task = tokio::spawn(async move {
6057            let req = rx.recv().await.unwrap();
6058            let body = req.prompt.clone();
6059            let scope = req.confirm_scope.clone().unwrap();
6060            let _ = req.response_tx.send(Some("y".into()));
6061            (body, scope)
6062        });
6063        let result = resolve_before_tool_call_result(
6064            crate::extensions::hooks::events::HookResult::Confirm {
6065                message: "Run this bash command?".into(),
6066                sources: ext(&["jev"]),
6067                generations: vec![],
6068                tool: Some("bash".into()),
6069                modified_input: Some(serde_json::json!({"command": "rm -rf build/"})),
6070                modified_by: ext(&["rewriter"]),
6071            },
6072            Some(&handle),
6073            false,
6074            Some(&latch),
6075        )
6076        .await;
6077        let (body, scope) = task.await.unwrap();
6078        let header_end = body.find("Message from jev:\nRun this bash command?").unwrap();
6079        let header = &body[..header_end];
6080        assert!(header.contains("Input rewritten by: rewriter"), "{body}");
6081        assert!(header.contains("Input that will run:\n  rm -rf build/"), "{body}");
6082        assert_eq!(scope.rewritten_by, ext(&["rewriter"]));
6083        assert!(matches!(
6084            result,
6085            crate::extensions::hooks::events::HookResult::Modify { input }
6086                if input == serde_json::json!({"command": "rm -rf build/"})
6087        ));
6088        // No rewrite → no rewrite header, empty rewritten_by.
6089        let plain = confirm_prompt_body("m", &ext(&["jev"]), Some("bash"), None);
6090        assert!(!plain.contains("rewritten"), "{plain}");
6091    }
6092
6093    /// prA I-R3-1: the preview is redacted before it is rendered.
6094    #[test]
6095    fn final_input_preview_redacts_credentials() {
6096        let secrets = [
6097            "SECRET-BEARER-123",
6098            "SECRET-KEY-456",
6099            "SECRET-FIELD-789",
6100            "QmFzaWNTRUNSRVQ=",
6101            "SECRET-TOK-000",
6102            "SECRET-FLAG-111",
6103            "sk-ant-api03-abcdefghijklmnop",
6104            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
6105            "ghp_abcdefghijklmnopqrstuvwxyz0123",
6106        ];
6107        let command = serde_json::json!({
6108            "command": "curl -H 'Authorization: Bearer SECRET-BEARER-123' \
6109                        'https://api.example/v1?api_key=SECRET-KEY-456' \
6110                        -H \"authorization: Basic QmFzaWNTRUNSRVQ=\" \
6111                        && TOKEN=SECRET-TOK-000 deploy --password SECRET-FLAG-111 \
6112                        sk-ant-api03-abcdefghijklmnop \
6113                        eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl \
6114                        ghp_abcdefghijklmnopqrstuvwxyz0123"
6115        });
6116        let json = serde_json::json!({
6117            "url": "https://x/y",
6118            "api_key": "SECRET-FIELD-789",
6119            "headers": {"X-Note": "Bearer SECRET-BEARER-123"},
6120            "args": ["--token=SECRET-TOK-000"],
6121        });
6122        for input in [command, json] {
6123            let p = final_input_preview(&input);
6124            for secret in secrets {
6125                assert!(!p.contains(secret), "{secret} leaked in:\n{p}");
6126            }
6127            assert!(p.contains("[REDACTED]"), "{p}");
6128        }
6129        // Non-secret text survives so the user can still judge the call.
6130        let plain = final_input_preview(&serde_json::json!({"command": "rm -rf build/ && ls -la"}));
6131        assert!(plain.contains("rm -rf build/ && ls -la"), "{plain}");
6132    }
6133
6134    /// prA I-R4-2: every shape from the round-4 table is redacted, by the
6135    /// SHARED scrubber (trace export gets the same rules).
6136    #[test]
6137    fn redact_secret_text_covers_round4_shapes() {
6138        let rows: &[(&str, &str)] = &[
6139            ("curl -H 'x-api-key: XAPIKEY-SECRET-1' https://a", "XAPIKEY-SECRET-1"),
6140            ("FOO_TOKEN=TOK-SECRET-2 run", "TOK-SECRET-2"),
6141            ("MY_SECRET=SEC-SECRET-3 ./x", "SEC-SECRET-3"),
6142            ("DB_PASSWORD=PW-SECRET-4 ./x", "PW-SECRET-4"),
6143            ("OPENAI_API_KEY=APIKEY-SECRET-5 ./x", "APIKEY-SECRET-5"),
6144            ("curl -H 'Authorization: Basic QkFTSUMtU0VDUkVULTY='", "QkFTSUMtU0VDUkVULTY="),
6145            ("aws_secret_access_key=AWSSEC-SECRET-7", "AWSSEC-SECRET-7"),
6146            ("git clone https://alice:USERINFO-SECRET-8@github.com/o/r", "USERINFO-SECRET-8"),
6147            ("psql postgres://app:PGPW-SECRET-9@db:5432/prod", "PGPW-SECRET-9"),
6148            ("DATABASE_URL=postgres://app:DBURL-SECRET-10@db/prod ./run", "DBURL-SECRET-10"),
6149            ("curl -H 'Cookie: sid=COOKIE-SECRET-11; theme=dark' https://a", "COOKIE-SECRET-11"),
6150            ("printf 'Set-Cookie: sid=SETCOOKIE-SECRET-12'", "SETCOOKIE-SECRET-12"),
6151            ("curl -u alice:CURLU-SECRET-13 https://a", "CURLU-SECRET-13"),
6152            ("mysql -u root -pMYSQL-SECRET-14 prod", "MYSQL-SECRET-14"),
6153            ("sshpass -p SSHPASS-SECRET-15 ssh host", "SSHPASS-SECRET-15"),
6154            ("aws configure set aws_secret_access_key AWSCFG-SECRET-16", "AWSCFG-SECRET-16"),
6155            ("aws x --secret-access-key FLAG-SECRET-17 --region eu", "FLAG-SECRET-17"),
6156            ("aws x --access-key FLAG-SECRET-18", "FLAG-SECRET-18"),
6157            ("STRIPE_KEY=rk_live_STRIPE-SECRET-19 node app", "STRIPE-SECRET-19"),
6158            ("OPENAI_KEY=OAIKEY-SECRET-20 node app", "OAIKEY-SECRET-20"),
6159        ];
6160        // prA I-R5-1: rows whose shape is unmistakable are also caught by
6161        // the SHARED high-precision scrub (trace export, memory, archive).
6162        let precise = [
6163            "QkFTSUMtU0VDUkVULTY=",
6164            "USERINFO-SECRET-8",
6165            "PGPW-SECRET-9",
6166            "DBURL-SECRET-10",
6167            "COOKIE-SECRET-11",
6168            "SETCOOKIE-SECRET-12",
6169            "CURLU-SECRET-13",
6170            "MYSQL-SECRET-14",
6171            "SSHPASS-SECRET-15",
6172        ];
6173        for (text, secret) in rows {
6174            // The Confirm prompt path catches every row.
6175            let out = redact_secret_text(text);
6176            assert!(!out.contains(secret), "{secret} leaked: {out}");
6177            assert!(out.contains("[REDACTED]"), "{out}");
6178            let preview = final_input_preview(&serde_json::json!({ "command": text }));
6179            assert!(!preview.contains(secret), "{secret} leaked in preview: {preview}");
6180            if precise.contains(secret) {
6181                let mut v = serde_json::Value::String(text.to_string());
6182                crate::runtime::trace::export::redact_value(&mut v);
6183                assert!(
6184                    !v.as_str().unwrap().contains(secret),
6185                    "shared scrub leaked {secret}: {v}"
6186                );
6187            }
6188        }
6189        // Over-redaction guards: ordinary commands and prose stay intact.
6190        for ok in [
6191            "mkdir -p build/out",
6192            "ssh -p 2222 host",
6193            "ssh -p2222 host",
6194            "docker run -p 8080:80 nginx",
6195            "mysql -h db -P 3306 -p prod",
6196            "curl -u alice https://example.com/path",
6197            "echo the token is valid and the password field is empty",
6198            "monkey=1 make",
6199            "git log --format=%H -- src/",
6200            "https://example.com/a/b?page=2",
6201        ] {
6202            assert_eq!(redact_secret_text(ok), ok, "over-redacted");
6203        }
6204    }
6205
6206    /// prA I-R5-1: the shared scrub (`redact_value`) also runs over the
6207    /// rollover context archive, memory captures and traces, so benign code
6208    /// and prose must pass through it unchanged. Only the Confirm prompt
6209    /// uses the aggressive rules.
6210    #[test]
6211    fn shared_scrub_leaves_benign_code_and_prose_alone() {
6212        let benign = [
6213            "max_tokens: 4096",
6214            "password: String,",
6215            "let token = load()?;",
6216            "the password-reset flow",
6217            "pub authorization: Option<String>,",
6218            "cookie: HashMap<String, String>,",
6219            "MY_KEY=value_in_docs",
6220            "--token-file path/to/file",
6221            "fn refresh_access_token(&self) -> Result<Token>",
6222            "aws_secret_access_key is the field name",
6223        ];
6224        for text in benign {
6225            let mut v = serde_json::Value::String(text.to_string());
6226            crate::runtime::trace::export::redact_value(&mut v);
6227            assert_eq!(v.as_str().unwrap(), text, "shared scrub mangled benign text");
6228            assert_eq!(crate::runtime::trace::export::redact_text(text), text);
6229        }
6230        // Inside objects too (archive / memory records are JSON).
6231        let mut record = serde_json::json!({
6232            "text": "set max_tokens: 4096 and keep the password-reset flow",
6233            "code": ["password: String,", "let token = load()?;"],
6234        });
6235        let before = record.clone();
6236        crate::runtime::trace::export::redact_value(&mut record);
6237        assert_eq!(record, before);
6238    }
6239
6240    /// prA I-R4-1: the confirmer's message is redacted too — it routinely
6241    /// echoes the (credential-injected) command it was shown.
6242    #[test]
6243    fn confirmer_message_echoing_injected_credentials_is_redacted() {
6244        let injected = serde_json::json!({
6245            "command": "curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a"
6246        });
6247        let body = confirm_prompt_body(
6248            "Run: curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a ?",
6249            &ext(&["jev"]),
6250            Some("bash"),
6251            Some((&ext(&["injector"]), &injected)),
6252        );
6253        assert!(!body.contains("ECHOED-BEARER-X"), "{body}");
6254        assert!(body.contains("Message from jev:\nRun: curl -H 'Authorization: [REDACTED]"), "{body}");
6255    }
6256
6257    /// prA I-R4-3: control, bidi and zero-width characters in the message
6258    /// are stripped before capping, so they can't move the cursor or
6259    /// disguise text in plain-text clients.
6260    #[test]
6261    fn confirm_message_control_and_bidi_chars_are_stripped() {
6262        let hostile = "ok\x1b[3A\x1b[2KExtension: trusted\rTool: none\u{0085}\u{202E}evil\u{200B}x\u{2066}y\tz\nnext";
6263        let body = confirm_prompt_body(hostile, &ext(&["jev"]), Some("bash"), None);
6264        for bad in ['\x1b', '\r', '\u{0085}', '\u{202E}', '\u{200B}', '\u{2066}', '\t'] {
6265            assert!(!body.contains(bad), "{bad:?} survived: {body:?}");
6266        }
6267        assert!(body.contains("Message from jev:\nok[3A[2KExtension: trustedTool: noneevilxy z\nnext"), "{body:?}");
6268        assert!(body.starts_with("Extension: jev\nTool: bash\n"));
6269    }
6270
6271    /// The confirmer's text is labelled, so a fake "Input that will run:"
6272    /// in it lands visibly under "Message from <ext>:", after the host block.
6273    #[test]
6274    fn extension_message_is_labelled_after_the_host_header() {
6275        let fake = "Input that will run:\n  echo harmless";
6276        let body = confirm_prompt_body(fake, &ext(&["jev"]), Some("bash"), None);
6277        let label = body.find("Message from jev:\n").expect(&body);
6278        assert_eq!(body.find("Input that will run:"), Some(label + "Message from jev:\n".len()));
6279        assert!(body.starts_with("Extension: jev\nTool: bash\n"), "{body}");
6280    }
6281
6282    #[test]
6283    fn final_input_preview_is_capped() {
6284        let many = serde_json::json!({"command": (1..=20).map(|i| format!("echo {i}")).collect::<Vec<_>>().join("\n")});
6285        let p = final_input_preview(&many);
6286        assert!(p.lines().count() <= MAX_REWRITE_PREVIEW_LINES + 1, "{p}");
6287        assert!(p.ends_with("…[preview truncated]"), "{p}");
6288        let wide = serde_json::json!({"path": "x".repeat(2000)});
6289        let p = final_input_preview(&wide);
6290        assert!(p.chars().count() <= MAX_REWRITE_PREVIEW_CHARS + 30, "{}", p.len());
6291        let obj = serde_json::json!({"path": "/tmp/a", "content": "hi"});
6292        assert!(final_input_preview(&obj).contains("\"path\": \"/tmp/a\""));
6293    }
6294
6295    /// A long extension message is capped so the host header, buttons and
6296    /// scope line stay on screen.
6297    #[test]
6298    fn long_confirm_message_is_capped() {
6299        let long = "x\n".repeat(500);
6300        let body = confirm_prompt_body(&long, &ext(&["jev"]), Some("bash"), None);
6301        assert!(body.starts_with("Extension: jev\nTool: bash\n"));
6302        assert!(body.contains("…[message truncated by host]"));
6303        assert!(body.lines().count() <= MAX_CONFIRM_MESSAGE_LINES + 8, "{}", body.lines().count());
6304        let wide = "y".repeat(10_000);
6305        assert!(confirm_prompt_body(&wide, &ext(&["jev"]), None, None).chars().count() < 1400);
6306    }
6307
6308    /// An unattributed confirm (no host-stamped source) offers no scope and
6309    /// an "always" answer approves only this call; nothing is latched.
6310    #[tokio::test]
6311    async fn unattributed_confirm_always_answer_does_not_latch() {
6312        let latch = crate::tools::SessionConfirmLatch::new();
6313        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6314        let handle = crate::tools::SecretPromptHandle::new(tx);
6315        let task = tokio::spawn(async move {
6316            let req = rx.recv().await.expect("prompt");
6317            assert!(req.confirm_scope.is_none());
6318            let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
6319        });
6320        let result = resolve_before_tool_call_result(
6321            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
6322            Some(&handle),
6323            false,
6324            Some(&latch),
6325        )
6326        .await;
6327        task.await.unwrap();
6328        assert!(matches!(
6329            result,
6330            crate::extensions::hooks::events::HookResult::Continue
6331        ));
6332        assert!(latch.is_empty());
6333    }
6334
6335    /// The latch only short-circuits Confirm; Block from an extension is
6336    /// still a Block, and a headless path with the source latched continues
6337    /// instead of failing closed (an unlatched source still fails closed).
6338    #[tokio::test]
6339    async fn session_allow_all_does_not_override_block() {
6340        let latch = crate::tools::SessionConfirmLatch::new();
6341        latch.allow(&key(&["jev"]));
6342        let blocked = resolve_before_tool_call_result(
6343            crate::extensions::hooks::events::HookResult::Block {
6344                reason: "nope".into(),
6345            },
6346            None,
6347            false,
6348            Some(&latch),
6349        )
6350        .await;
6351        assert!(matches!(
6352            blocked,
6353            crate::extensions::hooks::events::HookResult::Block { reason } if reason == "nope"
6354        ));
6355        let headless = resolve_before_tool_call_result(
6356            sourced_confirm("Run deploy?", "jev", "bash"),
6357            None,
6358            false,
6359            Some(&latch),
6360        )
6361        .await;
6362        assert!(matches!(
6363            headless,
6364            crate::extensions::hooks::events::HookResult::Continue
6365        ));
6366        let other = resolve_before_tool_call_result(
6367            sourced_confirm("Run deploy?", "other", "bash"),
6368            None,
6369            false,
6370            Some(&latch),
6371        )
6372        .await;
6373        assert!(matches!(
6374            other,
6375            crate::extensions::hooks::events::HookResult::Block { .. }
6376        ));
6377    }
6378
6379    #[test]
6380    fn test_max_tokens_for_model() {
6381        // Opus models should return 128000
6382        assert_eq!(
6383            HelperMethods::max_tokens_for_model("claude-opus-4-6"),
6384            128000
6385        );
6386        assert_eq!(
6387            HelperMethods::max_tokens_for_model("opus-something"),
6388            128000
6389        );
6390
6391        // Non-opus models should return 64000
6392        assert_eq!(
6393            HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
6394            64000
6395        );
6396        assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
6397        assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
6398        assert_eq!(
6399            HelperMethods::max_tokens_for_model("some-other-model"),
6400            64000
6401        );
6402
6403        // Edge cases
6404        assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
6405        assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); // Case sensitive - uppercase doesn't match
6406        assert_eq!(
6407            HelperMethods::max_tokens_for_model("model-opus-end"),
6408            128000
6409        ); // Contains "opus" anywhere
6410    }
6411
6412    #[test]
6413    fn test_truncate_tool_result() {
6414        let default_max = 30000;
6415
6416        // Short string should remain unchanged
6417        let short = "This is a short string.";
6418        assert_eq!(
6419            HelperMethods::truncate_tool_result(short, default_max),
6420            short
6421        );
6422
6423        // Exactly max should remain unchanged
6424        let exact = "x".repeat(30000);
6425        assert_eq!(
6426            HelperMethods::truncate_tool_result(&exact, default_max),
6427            exact
6428        );
6429
6430        // String longer than max should be truncated with notice
6431        let too_long = "x".repeat(30001);
6432        let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
6433
6434        // Should start with the truncated content
6435        assert!(truncated.starts_with(&"x".repeat(30000)));
6436
6437        // Should contain truncation notice with total char count
6438        assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
6439
6440        // Should be longer than max (due to notice)
6441        assert!(truncated.len() > 30000);
6442
6443        // Test with a much longer string
6444        let very_long = "a".repeat(50000);
6445        let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
6446        assert!(
6447            truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
6448        );
6449        assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
6450
6451        // Test with custom limit
6452        let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
6453        assert!(custom_truncated.starts_with(&"a".repeat(100)));
6454        assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
6455    }
6456
6457    /// T2 red→green: `truncate_tool_result` must enforce a BYTE budget on
6458    /// multibyte input (legacy code used `chars().take(n)` — a char budget —
6459    /// so 100 two-byte chars under a 50-byte budget kept 100 bytes).
6460    #[test]
6461    fn truncate_tool_result_enforces_byte_budget() {
6462        let multibyte = "é".repeat(100); // 200 bytes, 100 chars
6463        let out = HelperMethods::truncate_tool_result(&multibyte, 50);
6464        let body = out.split("\n\n[truncated").next().unwrap();
6465        assert!(
6466            body.len() <= 50,
6467            "retained body must fit the byte budget (got {} bytes)",
6468            body.len()
6469        );
6470        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
6471        assert!(
6472            out.contains("total bytes"),
6473            "marker must report byte counts, got: {out}"
6474        );
6475    }
6476
6477    #[test]
6478    fn test_thinking_level_ranges() {
6479        use crate::core::models::thinking_level_for_budget;
6480
6481        // Sentinel 0 = "adaptive" (S172 — model decides)
6482        assert_eq!(thinking_level_for_budget(0), "adaptive");
6483
6484        // Low range: 1..=2048
6485        assert_eq!(thinking_level_for_budget(1), "low");
6486        assert_eq!(thinking_level_for_budget(1024), "low");
6487        assert_eq!(thinking_level_for_budget(2048), "low");
6488
6489        // Medium range: 2049..=4096
6490        assert_eq!(thinking_level_for_budget(2049), "medium");
6491        assert_eq!(thinking_level_for_budget(3000), "medium");
6492        assert_eq!(thinking_level_for_budget(4096), "medium");
6493
6494        // High range: 4097..=16384
6495        assert_eq!(thinking_level_for_budget(4097), "high");
6496        assert_eq!(thinking_level_for_budget(8192), "high");
6497        assert_eq!(thinking_level_for_budget(16384), "high");
6498
6499        // XHigh range: _ (everything else)
6500        assert_eq!(thinking_level_for_budget(16385), "xhigh");
6501        assert_eq!(thinking_level_for_budget(32768), "xhigh");
6502        assert_eq!(thinking_level_for_budget(100000), "xhigh");
6503    }
6504}
6505
6506#[cfg(test)]
6507mod effective_prompt_tests {
6508    use super::*;
6509
6510    // ── progressive-disclosure tool catalog in the system prompt ────────────
6511
6512    struct LateTool;
6513    #[async_trait::async_trait]
6514    impl crate::Tool for LateTool {
6515        fn name(&self) -> &str {
6516            "late_tool"
6517        }
6518        fn description(&self) -> &str {
6519            "Registered after the catalog froze."
6520        }
6521        fn parameters(&self) -> Value {
6522            json!({"type": "object", "properties": {}})
6523        }
6524        async fn execute(&self, _p: Value, _c: crate::ToolContext) -> crate::Result<String> {
6525            Ok(String::new())
6526        }
6527    }
6528
6529    #[tokio::test]
6530    async fn tool_catalog_lists_activatable_tools_outside_the_core() {
6531        let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
6532        assert!(
6533            runtime.frozen_tool_catalog().await.is_none(),
6534            "flag off: no catalog"
6535        );
6536        runtime.progressive_tool_disclosure = true;
6537        let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
6538        assert!(catalog.starts_with("## Activatable tools\n"));
6539        assert!(
6540            catalog.contains("not instructions"),
6541            "third-party text is framed as data"
6542        );
6543        assert!(catalog.contains("- builtin:subagent_start"), "{catalog}");
6544        for core in [
6545            "builtin:bash",
6546            "builtin:read",
6547            "builtin:activate_tools",
6548            "builtin:search_tools",
6549        ] {
6550            assert!(
6551                !catalog.contains(&format!("- {core}")),
6552                "core {core} listed"
6553            );
6554        }
6555        let prompt = runtime.effective_system_prompt().await.unwrap();
6556        assert!(prompt.starts_with("BASE."), "base stays first");
6557        assert!(prompt.ends_with(&catalog), "catalog appended last");
6558    }
6559
6560    #[tokio::test]
6561    async fn tool_catalog_is_frozen_per_session() {
6562        let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
6563        runtime.progressive_tool_disclosure = true;
6564        let first = runtime.effective_system_prompt().await.unwrap();
6565        runtime.tools.write().await.register(Arc::new(LateTool));
6566        let clone = runtime.clone();
6567        assert_eq!(runtime.effective_system_prompt().await.unwrap(), first);
6568        assert_eq!(clone.effective_system_prompt().await.unwrap(), first);
6569        assert!(
6570            !first.contains("late_tool"),
6571            "late registration does not rewrite the prefix"
6572        );
6573    }
6574
6575    #[tokio::test]
6576    async fn tool_catalog_absent_when_activation_is_denied_or_impossible() {
6577        let mut denied = headless("anthropic/claude-sonnet-5-5", "BASE.");
6578        denied.progressive_tool_disclosure = true;
6579        denied.set_activation_confirm(agent_core::config::ActivationConfirm::Deny);
6580        assert!(denied.frozen_tool_catalog().await.is_none());
6581        assert_eq!(
6582            denied.effective_system_prompt().await.as_deref(),
6583            Some("BASE.")
6584        );
6585
6586        let mut worker = headless("anthropic/claude-sonnet-5-5", "BASE.");
6587        worker.progressive_tool_disclosure = true;
6588        worker.set_tools(crate::ToolRegistry::without_subagent());
6589        assert!(
6590            worker.frozen_tool_catalog().await.is_none(),
6591            "no activate_tools, no catalog"
6592        );
6593    }
6594
6595    fn headless(model: &str, base: &str) -> Runtime {
6596        let mut runtime = Runtime::new_headless();
6597        runtime.set_model(model.to_string());
6598        runtime.set_system_prompt(base.to_string());
6599        runtime
6600    }
6601
6602    #[tokio::test]
6603    async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
6604        let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
6605        runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
6606        let prompt = runtime.effective_system_prompt().await.expect("doctrine");
6607        assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
6608        assert!(prompt.contains("subagent_start"));
6609        assert!(prompt.contains("subagent_resume"));
6610    }
6611
6612    #[tokio::test]
6613    async fn codex_foreground_with_subagent_tools_composes_doctrine() {
6614        let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
6615        let prompt = runtime
6616            .effective_system_prompt()
6617            .await
6618            .expect("prompt must compose");
6619        assert!(prompt.starts_with("BASE."), "base must lead");
6620        assert!(prompt.contains("## Subagent supervision"));
6621        assert!(prompt.contains("NEVER end your turn"));
6622    }
6623
6624    #[tokio::test]
6625    async fn worker_runtime_without_subagent_tools_stays_clean() {
6626        let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
6627        runtime.set_tools(ToolRegistry::without_subagent());
6628        assert_eq!(
6629            runtime.effective_system_prompt().await.as_deref(),
6630            Some("WORKER."),
6631            "workers have no subagent tools; doctrine would be unactionable noise"
6632        );
6633    }
6634
6635    #[tokio::test]
6636    async fn non_codex_foreground_stays_clean() {
6637        let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
6638        assert_eq!(
6639            runtime.effective_system_prompt().await.as_deref(),
6640            Some("BASE.")
6641        );
6642    }
6643
6644    #[tokio::test]
6645    async fn manifest_prompt_is_returned_verbatim() {
6646        let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
6647        let module = agent_core::prompt::PromptModule::new(
6648            agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
6649            "1.0.0",
6650            agent_core::prompt::PromptModuleSource::User,
6651            0,
6652            agent_core::prompt::PromptSelectors::default(),
6653            agent_core::prompt::ModuleMutability::MutableGuidance,
6654            "KERNEL.",
6655        )
6656        .unwrap();
6657        let context = agent_core::prompt::SelectionContext::new(
6658            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
6659            None,
6660        )
6661        .unwrap();
6662        let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
6663        runtime.apply_prompt_stack(stack).unwrap();
6664        assert_eq!(
6665            runtime.effective_system_prompt().await.as_deref(),
6666            Some("KERNEL."),
6667            "manifest authors own the full stack; no builtin injection on top"
6668        );
6669    }
6670
6671    #[tokio::test]
6672    async fn unresolvable_model_identity_fails_closed_to_base() {
6673        let runtime = headless("definitely-not-a-model", "BASE.");
6674        assert_eq!(
6675            runtime.effective_system_prompt().await.as_deref(),
6676            Some("BASE.")
6677        );
6678    }
6679}
6680
6681#[cfg(test)]
6682mod set_reasoning_level_checked_tests {
6683    use super::*;
6684    use agent_core::reasoning::ReasoningLevel;
6685
6686    fn codex_runtime(model_id: &str) -> Runtime {
6687        let mut rt = Runtime::new_headless();
6688        rt.set_model(format!("openai-codex/{model_id}"));
6689        rt.set_reasoning_level(ReasoningLevel::Low);
6690        rt
6691    }
6692
6693    fn provisioned_fable_runtime() -> Runtime {
6694        let mut rt = Runtime::new_headless();
6695        rt.set_model("anthropic/claude-fable-5".into());
6696        rt.set_reasoning_level(ReasoningLevel::Low);
6697        let foreground =
6698            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
6699        let orchestration =
6700            crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
6701        rt.install_orchestration(Arc::new(orchestration));
6702        rt
6703    }
6704
6705    fn install_typed_manifest(rt: &mut Runtime) {
6706        let module = agent_core::prompt::PromptModule::new(
6707            agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
6708            "1.0.0",
6709            agent_core::prompt::PromptModuleSource::User,
6710            0,
6711            agent_core::prompt::PromptSelectors::default(),
6712            agent_core::prompt::ModuleMutability::MutableGuidance,
6713            "KERNEL.",
6714        )
6715        .unwrap();
6716        let context = agent_core::prompt::SelectionContext::new(
6717            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
6718            None,
6719        )
6720        .unwrap();
6721        rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
6722            .unwrap();
6723    }
6724
6725    #[test]
6726    fn provisioned_fable_accepts_max_and_ultracode() {
6727        for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
6728            let mut rt = provisioned_fable_runtime();
6729            rt.set_reasoning_level_checked(level).unwrap();
6730            assert_eq!(rt.reasoning_level(), level);
6731        }
6732    }
6733
6734    #[test]
6735    fn fable_ultracode_prerequisites_fail_without_mutation() {
6736        let mut missing_orchestration = Runtime::new_headless();
6737        missing_orchestration.set_model("anthropic/claude-fable-5".into());
6738        missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
6739        assert!(missing_orchestration
6740            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6741            .is_err());
6742        assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
6743
6744        let mut missing_tools = provisioned_fable_runtime();
6745        missing_tools.set_tools(ToolRegistry::without_subagent());
6746        assert!(missing_tools
6747            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6748            .is_err());
6749        assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
6750
6751        let mut worker = provisioned_fable_runtime();
6752        worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
6753        assert!(worker
6754            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6755            .is_err());
6756        assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
6757
6758        let mut internal = provisioned_fable_runtime();
6759        internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
6760        assert!(internal
6761            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6762            .is_err());
6763        assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
6764
6765        let mut manifest = provisioned_fable_runtime();
6766        install_typed_manifest(&mut manifest);
6767        assert!(manifest
6768            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6769            .is_err());
6770        assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
6771    }
6772
6773    #[test]
6774    fn checked_accepts_client_omission_modes_for_codex() {
6775        for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
6776            let mut rt = codex_runtime("gpt-5.6-sol");
6777            rt.set_reasoning_level_checked(level).unwrap();
6778            assert_eq!(rt.reasoning_level(), level);
6779        }
6780    }
6781
6782    /// luna does not support Ultra; checked must reject and leave level unchanged.
6783    #[test]
6784    fn checked_rejects_ultra_for_luna_no_mutation() {
6785        let mut rt = codex_runtime("gpt-5.6-luna");
6786        let before = rt.reasoning_level();
6787        let err = rt
6788            .set_reasoning_level_checked(ReasoningLevel::Ultra)
6789            .unwrap_err();
6790        assert!(
6791            err.contains("ultra"),
6792            "error must name the rejected level; got: {err}"
6793        );
6794        assert_eq!(
6795            rt.reasoning_level(),
6796            before,
6797            "runtime must not be mutated when validation fails"
6798        );
6799    }
6800
6801    /// gpt-5.5 does not support Max; checked must reject and not mutate.
6802    #[test]
6803    fn checked_rejects_max_for_gpt55_no_mutation() {
6804        let mut rt = codex_runtime("gpt-5.5");
6805        rt.set_reasoning_level(ReasoningLevel::Medium);
6806        let err = rt
6807            .set_reasoning_level_checked(ReasoningLevel::Max)
6808            .unwrap_err();
6809        assert!(
6810            err.contains("max"),
6811            "error must name the rejected level; got: {err}"
6812        );
6813        assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
6814    }
6815
6816    /// sol supports Ultra; checked must accept and mutate.
6817    #[test]
6818    fn checked_accepts_ultra_for_sol_and_mutates() {
6819        let mut rt = codex_runtime("gpt-5.6-sol");
6820        rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
6821            .expect("sol must accept ultra");
6822        assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
6823    }
6824
6825    /// Providers without exact metadata must not gain Max/Ultra.
6826    #[test]
6827    fn checked_rejects_extended_levels_without_metadata() {
6828        let mut rt = Runtime::new_headless();
6829        rt.set_model("claude-opus-4-7".to_string());
6830        rt.set_reasoning_level(ReasoningLevel::Low);
6831        for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
6832            assert!(rt.set_reasoning_level_checked(level).is_err());
6833            assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
6834        }
6835    }
6836
6837    /// Gap fix: unknown Codex ids (no cache, no static metadata) must reject
6838    /// the extended Max/Ultra modes at mutation time, fail closed.
6839    #[test]
6840    fn checked_rejects_max_ultra_for_unknown_codex_model() {
6841        let mut rt = codex_runtime("gpt-unknown-future");
6842        rt.set_reasoning_level(ReasoningLevel::Low);
6843        for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
6844            let err = rt.set_reasoning_level_checked(level).unwrap_err();
6845            assert!(err.contains("no capability metadata"), "{err}");
6846            assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
6847        }
6848    }
6849
6850    /// xAI: Off is rejected (not silently omitted) on models whose reasoning
6851    /// cannot be disabled; unsupported named efforts are rejected exactly.
6852    #[test]
6853    fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
6854        let mut rt = Runtime::new_headless();
6855        rt.set_model("xai-auth/grok-4.5".to_string());
6856        // Model default (not explicit) applied on switch: documented high.
6857        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6858        for level in [
6859            ReasoningLevel::Off,
6860            ReasoningLevel::XHigh,
6861            ReasoningLevel::Ultra,
6862        ] {
6863            let before = rt.reasoning_level();
6864            assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
6865            assert_eq!(rt.reasoning_level(), before);
6866        }
6867        rt.set_reasoning_level_checked(ReasoningLevel::Low)
6868            .expect("grok-4.5 supports low");
6869        assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
6870    }
6871
6872    /// xAI models without documented effort control reject explicit named
6873    /// levels; model switch applies the Adaptive provider-default.
6874    #[test]
6875    fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
6876        let mut rt = Runtime::new_headless();
6877        rt.set_model("xai-auth/grok-4.3".to_string());
6878        assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
6879        assert!(rt
6880            .set_reasoning_level_checked(ReasoningLevel::Medium)
6881            .is_err());
6882        assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
6883        rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
6884            .unwrap();
6885    }
6886
6887    /// Explicit user choice survives an xAI model switch (no default overwrite).
6888    #[test]
6889    fn explicit_level_survives_xai_model_switch() {
6890        let mut rt = Runtime::new_headless();
6891        rt.set_model("xai-auth/grok-4.5".to_string());
6892        rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
6893        rt.set_model("xai-auth/grok-4.5-latest".to_string());
6894        assert_eq!(
6895            rt.reasoning_level(),
6896            ReasoningLevel::Low,
6897            "explicit level must not be overwritten by the model default"
6898        );
6899    }
6900
6901    /// Explicit level the new model rejects is clamped to the model default;
6902    /// provenance stays explicit so later switches still honor the user.
6903    #[test]
6904    fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
6905        let mut rt = Runtime::new_headless();
6906        rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
6907        let clamp = rt
6908            .try_set_model("xai-auth/grok-4.6".to_string())
6909            .expect("model switch succeeds");
6910        assert_eq!(
6911            clamp,
6912            Some(ReasoningClamp {
6913                from: ReasoningLevel::XHigh,
6914                to: ReasoningLevel::High,
6915            })
6916        );
6917        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6918        assert!(
6919            rt.is_reasoning_explicit(),
6920            "clamp keeps explicit provenance"
6921        );
6922        assert_eq!(rt.model(), "xai-auth/grok-4.6");
6923    }
6924
6925    /// Explicit level the new model supports is left alone: no clamp reported.
6926    #[test]
6927    fn explicit_supported_level_is_not_clamped_on_switch() {
6928        let mut rt = Runtime::new_headless();
6929        rt.set_reasoning_level_explicit(ReasoningLevel::High);
6930        let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
6931        assert_eq!(clamp, None);
6932        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6933        assert!(rt.is_reasoning_explicit());
6934    }
6935
6936    /// Non-explicit levels keep taking the new model's default (unchanged
6937    /// behavior) and report no clamp — the default is applied, not substituted.
6938    #[test]
6939    fn non_explicit_default_application_reports_no_clamp() {
6940        let mut rt = Runtime::new_headless();
6941        rt.set_reasoning_level(ReasoningLevel::XHigh);
6942        let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
6943        assert_eq!(clamp, None);
6944        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6945        assert!(!rt.is_reasoning_explicit());
6946    }
6947
6948    /// Explicit Max on an xAI model without Max support clamps to a level that
6949    /// validates for that exact model (documented default Adaptive).
6950    #[test]
6951    fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
6952        let model = "xai-auth/grok-4.20-multi-agent-0309";
6953        let mut rt = Runtime::new_headless();
6954        rt.set_reasoning_level_explicit(ReasoningLevel::Max);
6955        let clamp = rt
6956            .try_set_model(model.to_string())
6957            .unwrap()
6958            .expect("Max is not supported: must clamp");
6959        assert_eq!(clamp.from, ReasoningLevel::Max);
6960        assert!(
6961            crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
6962                model, clamp.to
6963            )
6964            .is_ok(),
6965            "clamped level {} must validate",
6966            clamp.to
6967        );
6968        assert_eq!(rt.reasoning_level(), clamp.to);
6969        assert!(rt.is_reasoning_explicit());
6970    }
6971
6972    /// `set_model` (infallible wrapper) still surfaces the clamp.
6973    #[test]
6974    fn set_model_returns_clamp() {
6975        let mut rt = Runtime::new_headless();
6976        rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
6977        let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
6978        assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
6979    }
6980
6981    /// Resume path: a saved grok+xhigh session (written before clamping
6982    /// existed) must not resume into a permanently failing state.
6983    #[test]
6984    fn restore_session_reasoning_clamps_after_reapply() {
6985        let mut rt = Runtime::new_headless();
6986        rt.set_model("xai-auth/grok-4.6".to_string());
6987        let clamp = rt.restore_session_reasoning("xhigh");
6988        assert_eq!(
6989            clamp,
6990            Some(ReasoningClamp {
6991                from: ReasoningLevel::XHigh,
6992                to: ReasoningLevel::High,
6993            })
6994        );
6995        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6996        assert!(rt.is_reasoning_explicit());
6997        assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
6998    }
6999
7000    /// Supported saved level restores verbatim with no clamp.
7001    #[test]
7002    fn restore_session_reasoning_keeps_supported_level() {
7003        let mut rt = Runtime::new_headless();
7004        rt.set_model("xai-auth/grok-4.6".to_string());
7005        assert_eq!(rt.restore_session_reasoning("low"), None);
7006        assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
7007        assert!(rt.is_reasoning_explicit());
7008    }
7009
7010    /// Boot path: config `model` + unsupported explicit `thinking` no longer
7011    /// boots into a permanently failing state.
7012    #[test]
7013    fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
7014        let mut rt = Runtime::new_headless();
7015        rt.apply_config(&crate::config::SynapsConfig {
7016            model: Some("xai-auth/grok-4.6".to_string()),
7017            thinking_level: Some(ReasoningLevel::XHigh),
7018            ..Default::default()
7019        });
7020        assert_eq!(rt.model(), "xai-auth/grok-4.6");
7021        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
7022        assert!(rt.is_reasoning_explicit());
7023        assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
7024    }
7025}
7026
7027#[cfg(test)]
7028mod codex_execution_preflight_tests {
7029    use super::*;
7030    use crate::runtime::openai::catalog::CodexRequestRole;
7031    use agent_core::prompt::QualifiedModelId;
7032    use agent_core::reasoning::ReasoningLevel;
7033
7034    fn ultra_runtime() -> Runtime {
7035        let mut runtime = Runtime::new_headless();
7036        runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
7037        runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
7038        runtime
7039    }
7040
7041    fn install_baseline(runtime: &mut Runtime) {
7042        let foreground =
7043            QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
7044        let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
7045            .expect("baseline orchestration");
7046        runtime.install_orchestration(Arc::new(orchestration));
7047    }
7048
7049    #[tokio::test]
7050    async fn foreground_ultra_preflight_requires_orchestration() {
7051        let runtime = ultra_runtime();
7052        let error = runtime
7053            .validate_request_preflight()
7054            .await
7055            .expect_err("Ultra without orchestration must fail closed");
7056        assert!(error.to_string().contains("orchestration"), "{error}");
7057    }
7058
7059    #[tokio::test]
7060    async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
7061        let mut runtime = ultra_runtime();
7062        install_baseline(&mut runtime);
7063        runtime.set_tools(ToolRegistry::without_subagent());
7064
7065        let error = runtime
7066            .validate_request_preflight()
7067            .await
7068            .expect_err("Ultra without subagent tools must fail closed");
7069        assert!(error.to_string().contains("subagent"), "{error}");
7070    }
7071
7072    #[tokio::test]
7073    async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
7074        let mut runtime = ultra_runtime();
7075        install_baseline(&mut runtime);
7076        runtime
7077            .validate_request_preflight()
7078            .await
7079            .expect("fully provisioned Ultra must pass preflight");
7080    }
7081
7082    #[tokio::test]
7083    async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
7084        let mut runtime = ultra_runtime();
7085        runtime.set_codex_request_role(CodexRequestRole::Worker);
7086        runtime.set_tools(ToolRegistry::without_subagent());
7087        runtime
7088            .validate_request_preflight()
7089            .await
7090            .expect("worker Ultra is underlying max without proactive orchestration");
7091        assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
7092    }
7093
7094    #[tokio::test]
7095    async fn non_codex_ultra_preflight_fails_before_provider_work() {
7096        let mut runtime = Runtime::new_headless();
7097        runtime.set_model("anthropic/claude-opus-4-7".to_string());
7098        runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
7099        let error = runtime
7100            .validate_request_preflight()
7101            .await
7102            .expect_err("non-Codex Ultra must fail closed");
7103        assert!(
7104            error.to_string().contains("unsupported_reasoning_level"),
7105            "{error}"
7106        );
7107    }
7108
7109    #[tokio::test]
7110    async fn compaction_preflight_validates_target_before_credentials() {
7111        let mut runtime = ultra_runtime();
7112        runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
7113        {
7114            let mut auth = runtime.auth.write().await;
7115            auth.auth_token.clear();
7116            auth.auth_type = "none".to_string();
7117            auth.refresh_token = None;
7118            auth.token_expires = None;
7119        }
7120
7121        let error = runtime
7122            .compact_call(Vec::new())
7123            .await
7124            .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
7125        assert!(
7126            error.to_string().contains("authoritative exact-model"),
7127            "preflight must reject the target before auth access: {error}"
7128        );
7129    }
7130}
7131
7132#[cfg(test)]
7133mod http_client_timeout_tests {
7134    //! Regression tests for the runtime HTTP client's timeout semantics.
7135    //!
7136    //! The client used to set `.timeout(300s)` — a **total wall-clock
7137    //! deadline** that keeps ticking even while a streaming response is
7138    //! actively delivering bytes (reqwest `TotalTimeoutBody` never resets).
7139    //! Two failure modes: a dead connection took a full 5 minutes to
7140    //! surface (incident: session 20260714-025948-3dab), and any healthy
7141    //! stream longer than 300s was killed mid-flight. The fix is an
7142    //! idle-based `read_timeout` that resets on every received chunk.
7143    //!
7144    //! Servers here are raw TCP writing chunked HTTP/1.1 — full control
7145    //! over inter-chunk delays with no framework buffering in the way.
7146
7147    use futures::StreamExt;
7148    use std::time::Duration;
7149    use tokio::io::AsyncWriteExt;
7150
7151    const CHUNKED_HEAD: &[u8] =
7152        b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
7153
7154    fn chunk(data: &str) -> Vec<u8> {
7155        format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
7156    }
7157
7158    /// Spawn a raw server: writes headers, then `n_chunks` chunks spaced
7159    /// `gap` apart, then (optionally) the terminating chunk.
7160    async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
7161        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7162        let addr = listener.local_addr().unwrap();
7163        tokio::spawn(async move {
7164            let (mut sock, _) = listener.accept().await.unwrap();
7165            // Drain the request head (we don't care about its contents).
7166            let mut buf = [0u8; 4096];
7167            use tokio::io::AsyncReadExt;
7168            let _ = sock.read(&mut buf).await;
7169            sock.write_all(CHUNKED_HEAD).await.unwrap();
7170            for i in 0..n_chunks {
7171                sock.write_all(&chunk(&format!("data {i}\n")))
7172                    .await
7173                    .unwrap();
7174                sock.flush().await.unwrap();
7175                tokio::time::sleep(gap).await;
7176            }
7177            if terminate {
7178                sock.write_all(b"0\r\n\r\n").await.unwrap();
7179            } else {
7180                // Stall forever (until the client gives up and drops us).
7181                tokio::time::sleep(Duration::from_secs(60)).await;
7182            }
7183        });
7184        format!("http://{addr}/stream")
7185    }
7186
7187    async fn drain(
7188        client: &reqwest::Client,
7189        url: &str,
7190    ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
7191        let resp = client.get(url).send().await?;
7192        let mut stream = resp.bytes_stream();
7193        let mut total = 0usize;
7194        while let Some(c) = stream.next().await {
7195            total += c?.len();
7196        }
7197        Ok(total)
7198    }
7199
7200    /// A healthy stream that keeps delivering bytes must NOT be killed,
7201    /// even when its total duration exceeds the read timeout — received
7202    /// data resets the idle clock. (This is the property the old total
7203    /// deadline violated at 300s.)
7204    #[tokio::test]
7205    async fn active_stream_outliving_read_timeout_survives() {
7206        // 15 chunks × 100ms ≈ 1.5s total, read_timeout = 400ms.
7207        let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
7208        let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
7209        let total = drain(&client, &url)
7210            .await
7211            .expect("active stream must never be killed by the idle timeout");
7212        assert!(total > 0);
7213    }
7214
7215    /// A stream that stalls (bytes stop arriving) must be killed within
7216    /// the read timeout — not after a 300s total deadline.
7217    #[tokio::test]
7218    async fn stalled_stream_is_killed_by_read_timeout() {
7219        // 2 quick chunks then permanent stall, read_timeout = 300ms.
7220        let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
7221        let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
7222        let start = std::time::Instant::now();
7223        let err = drain(&client, &url)
7224            .await
7225            .expect_err("stalled stream must be detected");
7226        assert!(
7227            start.elapsed() < Duration::from_secs(5),
7228            "stall must be detected promptly, took {:?}",
7229            start.elapsed()
7230        );
7231        let msg = crate::core::error::error_chain_string(err.as_ref());
7232        assert!(
7233            msg.contains("timed out") || msg.contains("timeout"),
7234            "must be a timeout error: {msg}"
7235        );
7236    }
7237
7238    /// A server that accepts but never sends response headers must be
7239    /// killed by the read timeout too (the incident's failure mode —
7240    /// previously took the full 300s total deadline to surface).
7241    #[tokio::test]
7242    async fn hung_headers_are_killed_by_read_timeout() {
7243        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7244        let addr = listener.local_addr().unwrap();
7245        tokio::spawn(async move {
7246            let (_sock, _) = listener.accept().await.unwrap();
7247            tokio::time::sleep(Duration::from_secs(60)).await;
7248        });
7249        let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
7250        let start = std::time::Instant::now();
7251        let err = client
7252            .get(format!("http://{addr}/codex/responses"))
7253            .send()
7254            .await
7255            .expect_err("hung request must be detected");
7256        assert!(
7257            start.elapsed() < Duration::from_secs(5),
7258            "hung headers must be detected promptly, took {:?}",
7259            start.elapsed()
7260        );
7261        assert!(err.is_timeout(), "must be a timeout: {err:?}");
7262    }
7263}
7264
7265/// Task A6: memory-context provider validation + revocation wiring.
7266#[cfg(test)]
7267mod memory_context_provider_tests {
7268    use super::{memory_context, Runtime};
7269    use std::sync::Arc;
7270
7271    // ── task A6: memory-context provider validation + revocation wiring ──
7272
7273    /// Build a headless Runtime with the extension runtime installed (the
7274    /// exact `install_extension_runtime` wiring point the engine uses at
7275    /// boot) and one deferred context-provider-only extension loaded per
7276    /// `(plugin, provider-id)` pair. Nothing spawns: context-provider
7277    /// manifests classify as deferred and stay dormant.
7278    async fn memory_runtime_with_providers(
7279        plugins: &[(&str, &str)],
7280    ) -> (
7281        Runtime,
7282        std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
7283    ) {
7284        let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
7285            crate::extensions::hooks::HookBus::new(),
7286        ));
7287        manager.set_progressive_deferral(true);
7288        for (plugin, provider) in plugins {
7289            let manifest: crate::extensions::manifest::ExtensionManifest =
7290                serde_json::from_value(serde_json::json!({
7291                    "runtime": "process",
7292                    "command": "/bin/false",
7293                    "permissions": ["context_providers.register"],
7294                    "deferred": {
7295                        "context_providers": [{
7296                            "id": provider,
7297                            "capability": "project-memory",
7298                            "description": "test context provider",
7299                            "schema_version": 1
7300                        }]
7301                    }
7302                }))
7303                .expect("manifest parses");
7304            manager
7305                .load(plugin, &manifest)
7306                .await
7307                .expect("deferred context-provider load succeeds without spawning");
7308        }
7309        let extension_runtime = manager.extension_runtime();
7310        let mut runtime = Runtime::new_headless();
7311        runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
7312        (runtime, extension_runtime)
7313    }
7314
7315    fn assert_memory_off_no_lease(runtime: &Runtime) {
7316        let status = runtime.memory_context_status();
7317        assert_eq!(
7318            status.durable,
7319            memory_context::DurableStatus::Off,
7320            "state must stay Off"
7321        );
7322        assert_eq!(
7323            status.one_shot,
7324            memory_context::OneShotStatus::Idle,
7325            "no one-shot lease may exist"
7326        );
7327        assert!(
7328            runtime.memory_bound_providers_for_test().is_empty(),
7329            "no provider may be bound"
7330        );
7331    }
7332
7333    // ── memory-backend configuration tests (ported from upstream) ────────
7334
7335    #[tokio::test]
7336    async fn memory_backend_exclusive_rejects_extension_recall_capture_and_history() {
7337        for selector in ["axel", "invalid-selector"] {
7338            let (mut runtime, _manager) =
7339                memory_runtime_with_providers(&[("memory-test", "notes")]).await;
7340            let provider = runtime.resolve_memory_provider(None).unwrap();
7341            runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
7342                "memory.backend = {selector}\n"
7343            )));
7344            assert!(runtime.memory_backend.exclusive());
7345            assert!(matches!(
7346                runtime.resolve_memory_provider(None),
7347                Err(memory_context::MemoryContextError::ProviderNotRegistered)
7348            ));
7349            assert!(matches!(
7350                runtime.resolve_memory_provider(Some(provider.as_str())),
7351                Err(memory_context::MemoryContextError::ProviderNotRegistered)
7352            ));
7353            assert!(matches!(
7354                runtime.memory_history_confirm(),
7355                Err(
7356                    super::memory_history::HistoryImportError::CaptureProviderUnavailable
7357                        | super::memory_history::HistoryImportError::ConsentRequired
7358                )
7359            ));
7360            assert_memory_off_no_lease(&runtime);
7361            let mut messages = vec![Arc::new(
7362                serde_json::json!({"role": "user", "content": "test"}),
7363            )];
7364            let original = messages.clone();
7365            runtime.apply_turn_memory_recall(&mut messages).await;
7366            assert_eq!(messages, original);
7367            let clone = runtime.clone();
7368            assert!(clone.memory_backend.exclusive());
7369            assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
7370        }
7371    }
7372
7373    #[tokio::test]
7374    async fn memory_backend_first_config_is_immutable_and_changes_require_restart() {
7375        for (first, different) in [("legacy", "axel"), ("axel", "legacy")] {
7376            let mut runtime = Runtime::new_headless();
7377            let config =
7378                agent_core::config::load_config_from_str(&format!("memory.backend = {first}\n"));
7379            runtime.apply_config(&config);
7380            let binding = runtime.memory_backend.clone();
7381            runtime.apply_config(&config);
7382            assert!(!runtime
7383                .memory_backend_reconfigure_denied
7384                .load(std::sync::atomic::Ordering::SeqCst));
7385            if let (Ok(a), Ok(b)) = (binding.scope(), runtime.memory_backend.scope()) {
7386                assert!(std::ptr::eq(a, b));
7387            }
7388            let clone = runtime.clone();
7389            runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
7390                "memory.backend = {different}\n"
7391            )));
7392            assert_eq!(runtime.memory_backend.exclusive(), binding.exclusive());
7393            assert_eq!(
7394                runtime.memory_backend_config.as_ref(),
7395                Some(&config.memory_backend)
7396            );
7397            for denied in [&runtime, &clone] {
7398                let error = denied
7399                    .validate_request_preflight()
7400                    .await
7401                    .unwrap_err()
7402                    .to_string();
7403                assert!(error.contains("restart required"), "{error}");
7404                assert!(denied.resolve_memory_provider(None).is_err());
7405            }
7406            runtime.apply_config(&config);
7407            assert!(runtime.validate_request_preflight().await.is_err());
7408        }
7409    }
7410
7411    #[tokio::test]
7412    async fn memory_backend_path_changes_also_require_restart() {
7413        for field in ["executable", "brain"] {
7414            let mut runtime = Runtime::new_headless();
7415            let config = crate::config::MemoryBackendConfig::default();
7416            runtime.apply_memory_backend_config(&config);
7417            let mut different = config.clone();
7418            let path = std::env::temp_dir().join("synaps-memory-config-test");
7419            if field == "executable" {
7420                different.executable = Some(path);
7421            } else {
7422                different.brain = Some(path);
7423            }
7424            runtime.apply_memory_backend_config(&different);
7425            assert_eq!(runtime.memory_backend_config.as_ref(), Some(&config));
7426            assert!(!runtime.memory_backend.exclusive());
7427            let error = runtime
7428                .validate_request_preflight()
7429                .await
7430                .unwrap_err()
7431                .to_string();
7432            assert!(error.contains("restart required"), "{error}");
7433            assert!(runtime.resolve_memory_provider(None).is_err());
7434        }
7435    }
7436
7437    fn default_mode_config(mode: &str, confirmed: bool) -> agent_core::config::MemoryConfig {
7438        agent_core::config::MemoryConfig {
7439            default_mode: mode.to_string(),
7440            default_mode_confirmed: confirmed,
7441            ..Default::default()
7442        }
7443    }
7444
7445    /// Spec §12: a confirmed non-off `memory.default_mode` starts the
7446    /// session in that mode under the host-owned `OperatorConfig` proof.
7447    #[test]
7448    fn memory_default_mode_confirmed_grants_session_lease() {
7449        for (name, mode) in [
7450            (
7451                "recall_each_prompt",
7452                memory_context::MemoryContextMode::RecallEachPrompt,
7453            ),
7454            (
7455                "capture_only",
7456                memory_context::MemoryContextMode::CaptureOnly,
7457            ),
7458            (
7459                "capture_and_recall",
7460                memory_context::MemoryContextMode::CaptureAndRecall,
7461            ),
7462        ] {
7463            let runtime = Runtime::new_headless();
7464            let status = runtime
7465                .apply_memory_default_mode(&default_mode_config(name, true))
7466                .expect("a confirmed non-off default applies")
7467                .expect("legacy backend grants");
7468            assert!(
7469                matches!(status.durable, memory_context::DurableStatus::Active { mode: m, .. } if m == mode),
7470                "{name}: got {:?}",
7471                status.durable
7472            );
7473            assert_eq!(
7474                runtime.memory_durable_proof_for_test(),
7475                Some(memory_context::UserIntentProof::OperatorConfig),
7476                "{name}"
7477            );
7478        }
7479
7480        let runtime = Runtime::new_headless();
7481        runtime
7482            .apply_memory_default_mode(&default_mode_config("recall_once", true))
7483            .expect("applies")
7484            .expect("grants");
7485        assert_eq!(
7486            runtime.memory_context_status().durable,
7487            memory_context::DurableStatus::Off
7488        );
7489        assert_eq!(
7490            runtime.memory_one_shot_proof_for_test(),
7491            Some(memory_context::UserIntentProof::OperatorConfig)
7492        );
7493    }
7494
7495    /// Off, unknown, or unconfirmed defaults grant nothing (fail closed).
7496    #[test]
7497    fn memory_default_mode_off_unknown_or_unconfirmed_grants_nothing() {
7498        for (mode, confirmed) in [
7499            ("off", true),
7500            ("off", false),
7501            ("capture_and_recall", false),
7502            ("recall_once", false),
7503            ("recall_everything_forever", true),
7504        ] {
7505            let runtime = Runtime::new_headless();
7506            assert!(
7507                runtime
7508                    .apply_memory_default_mode(&default_mode_config(mode, confirmed))
7509                    .is_none(),
7510                "{mode}/{confirmed} must not apply"
7511            );
7512            assert_memory_off_no_lease(&runtime);
7513        }
7514    }
7515
7516    /// The default never overrides memory state the session already has,
7517    /// and `/memory off` still revokes a defaulted lease.
7518    #[test]
7519    fn memory_default_mode_keeps_existing_state_and_off_revokes() {
7520        let runtime = Runtime::new_headless();
7521        runtime
7522            .memory_context_enable(
7523                memory_context::MemoryContextMode::RecallEachPrompt,
7524                memory_context::mint_explicit_command_proof(),
7525            )
7526            .unwrap();
7527        assert!(runtime
7528            .apply_memory_default_mode(&default_mode_config("capture_and_recall", true))
7529            .is_none());
7530        assert!(matches!(
7531            runtime.memory_context_status().durable,
7532            memory_context::DurableStatus::Active {
7533                mode: memory_context::MemoryContextMode::RecallEachPrompt,
7534                ..
7535            }
7536        ));
7537        assert!(matches!(
7538            runtime.memory_durable_proof_for_test(),
7539            Some(memory_context::UserIntentProof::ExplicitCommand { .. })
7540        ));
7541
7542        let runtime = Runtime::new_headless();
7543        runtime
7544            .apply_memory_default_mode(&default_mode_config("capture_and_recall", true))
7545            .unwrap()
7546            .unwrap();
7547        runtime.memory_context_disable();
7548        assert_memory_off_no_lease(&runtime);
7549    }
7550
7551    /// A default whose backend cannot serve fails closed: typed error,
7552    /// memory stays off, no fallback provider.
7553    #[test]
7554    fn memory_default_mode_unusable_backend_fails_closed() {
7555        let mut runtime = Runtime::new_headless();
7556        runtime.apply_config(&agent_core::config::load_config_from_str(
7557            "memory.backend = invalid-not-axel-not-legacy\n",
7558        ));
7559        assert!(runtime
7560            .apply_memory_default_mode(&default_mode_config("capture_and_recall", true))
7561            .expect("a confirmed default is attempted")
7562            .is_err());
7563        assert_memory_off_no_lease(&runtime);
7564    }
7565
7566    #[test]
7567    fn memory_backend_apply_config_revokes_preexisting_legacy_lease() {
7568        let mut runtime = Runtime::new_headless();
7569        runtime
7570            .memory_context_enable(
7571                memory_context::MemoryContextMode::CaptureAndRecall,
7572                memory_context::UserIntentProof::ExplicitCommand {
7573                    command_id: memory_context::mint_explicit_command_id(),
7574                },
7575            )
7576            .unwrap();
7577        let lease = runtime
7578            .memory_context_lock()
7579            .capture_lease_at(std::time::SystemTime::now())
7580            .unwrap();
7581        runtime.apply_config(&agent_core::config::load_config_from_str(
7582            "memory.backend = axel\n",
7583        ));
7584        assert_memory_off_no_lease(&runtime);
7585        assert!(runtime.extension_capture_provider(&lease).is_none());
7586    }
7587
7588    #[test]
7589    fn axel_invalid_config_cannot_grant_consent_or_fallback() {
7590        let mut runtime = Runtime::new_headless();
7591        runtime.apply_config(&agent_core::config::load_config_from_str(
7592            "memory.backend = invalid-not-axel-not-legacy\n",
7593        ));
7594        assert!(runtime.memory_backend.exclusive());
7595        assert!(runtime.resolve_memory_provider(None).is_err());
7596        assert_memory_off_no_lease(&runtime);
7597    }
7598
7599    /// Task A6: enabling against a catalog that does not contain the
7600    /// requested provider fails closed — typed error, nothing granted,
7601    /// `SessionMemoryState` unchanged (Off/no-lease) — both for an
7602    /// explicitly requested nonexistent id and for an id-less request
7603    /// against an empty catalog.
7604    #[tokio::test]
7605    async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
7606        // Explicit nonexistent id against a catalog with one real provider.
7607        let (runtime, _ext) =
7608            memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7609        for requested in ["no-such-provider", "bad:id"] {
7610            let err = runtime
7611                .memory_context_enable_resolved(
7612                    memory_context::MemoryContextMode::CaptureAndRecall,
7613                    memory_context::mint_explicit_command_proof(),
7614                    Some(requested),
7615                )
7616                .expect_err("unregistered provider must fail closed");
7617            assert_eq!(
7618                err,
7619                memory_context::MemoryContextError::ProviderNotRegistered
7620            );
7621            assert_memory_off_no_lease(&runtime);
7622        }
7623
7624        // Id-less request against an EMPTY catalog (extension subsystem
7625        // installed, no context-provider extension loaded).
7626        let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
7627        let err = runtime
7628            .memory_context_enable(
7629                memory_context::MemoryContextMode::CaptureAndRecall,
7630                memory_context::mint_explicit_command_proof(),
7631            )
7632            .expect_err("empty catalog must fail closed");
7633        assert_eq!(
7634            err,
7635            memory_context::MemoryContextError::ProviderNotRegistered
7636        );
7637        assert_memory_off_no_lease(&runtime);
7638        // One-shot grants run the same validation.
7639        let err = runtime
7640            .memory_context_recall_once(memory_context::mint_explicit_command_proof())
7641            .expect_err("one-shot grant must validate the provider too");
7642        assert_eq!(
7643            err,
7644            memory_context::MemoryContextError::ProviderNotRegistered
7645        );
7646        assert_memory_off_no_lease(&runtime);
7647    }
7648
7649    /// Task A6: with exactly one matching declared provider the grant
7650    /// succeeds and records THAT provider's exact composed runtime
7651    /// address — for the default id-less request and the explicit exact id.
7652    #[tokio::test]
7653    async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
7654        const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
7655        // Default (id-less) request.
7656        let (runtime, _ext) =
7657            memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7658        let status = runtime
7659            .memory_context_enable(
7660                memory_context::MemoryContextMode::CaptureAndRecall,
7661                memory_context::mint_explicit_command_proof(),
7662            )
7663            .expect("unique declared provider must resolve");
7664        assert!(matches!(
7665            status.durable,
7666            memory_context::DurableStatus::Active { .. }
7667        ));
7668        let bound = runtime.memory_bound_providers_for_test();
7669        assert_eq!(bound.len(), 1);
7670        assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
7671
7672        // Explicit exact id resolves to the same identity.
7673        let (runtime, _ext) =
7674            memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7675        runtime
7676            .memory_context_enable_resolved(
7677                memory_context::MemoryContextMode::RecallEachPrompt,
7678                memory_context::mint_explicit_command_proof(),
7679                Some("project-memory"),
7680            )
7681            .expect("explicit exact id must resolve");
7682        assert_eq!(
7683            runtime.memory_bound_providers_for_test()[0].as_str(),
7684            ADDRESS
7685        );
7686    }
7687
7688    /// Task A6: two installed extensions declaring overlapping context
7689    /// providers make both the id-less request AND a request for the
7690    /// overlapping id ambiguous — fail closed, nothing granted.
7691    #[tokio::test]
7692    async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
7693        let (runtime, _ext) = memory_runtime_with_providers(&[
7694            ("mem-a", "project-memory"),
7695            ("mem-b", "project-memory"),
7696        ])
7697        .await;
7698        let err = runtime
7699            .memory_context_enable(
7700                memory_context::MemoryContextMode::CaptureAndRecall,
7701                memory_context::mint_explicit_command_proof(),
7702            )
7703            .expect_err("overlapping declarations must fail closed");
7704        assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
7705        assert_memory_off_no_lease(&runtime);
7706
7707        // The overlapping exact id cannot disambiguate two owners either.
7708        let err = runtime
7709            .memory_context_enable_resolved(
7710                memory_context::MemoryContextMode::CaptureOnly,
7711                memory_context::mint_explicit_command_proof(),
7712                Some("project-memory"),
7713            )
7714            .expect_err("same id declared by two plugins is ambiguous");
7715        assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
7716        assert_memory_off_no_lease(&runtime);
7717    }
7718
7719    /// Task A6 revocation wiring: disable revokes the granted lease from
7720    /// `SessionMemoryState` AND defensively calls
7721    /// `ExtensionRuntimeManager::revoke_plugin_lease` for the bound
7722    /// plugin/session pair — an idempotent no-op (no panic) when nothing
7723    /// was ever spawned.
7724    #[tokio::test]
7725    async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
7726        let (runtime, ext) =
7727            memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7728        runtime
7729            .memory_context_enable(
7730                memory_context::MemoryContextMode::CaptureAndRecall,
7731                memory_context::mint_explicit_command_proof(),
7732            )
7733            .expect("enable succeeds");
7734        assert_eq!(
7735            ext.lease_count(),
7736            0,
7737            "granting a memory lease spawns nothing"
7738        );
7739
7740        let status = runtime.memory_context_disable();
7741        assert_eq!(status.durable, memory_context::DurableStatus::Off);
7742        assert_memory_off_no_lease(&runtime);
7743        assert_eq!(
7744            ext.lease_count(),
7745            0,
7746            "revocation of a never-spawned lease is a no-op"
7747        );
7748
7749        // Idempotent: disabling again is safe and stays Off.
7750        let status = runtime.memory_context_disable();
7751        assert_eq!(status.durable, memory_context::DurableStatus::Off);
7752        assert_eq!(ext.lease_count(), 0);
7753    }
7754
7755    /// Task B5 (spec §15): enable and disable emit metadata-only
7756    /// `memory_context.enabled` / `memory_context.disabled` events — mode +
7757    /// identities with the host-derived project DIGEST, never the raw
7758    /// project path; a failed enable emits nothing.
7759    #[test]
7760    fn memory_context_enable_disable_emit_metadata_only_events() {
7761        memory_context::drain_captured_memory_events_for_test();
7762
7763        let runtime = Runtime::new_headless();
7764        // A rejected enable (Off is not a session-lease mode) emits nothing.
7765        runtime
7766            .memory_context_enable(
7767                memory_context::MemoryContextMode::Off,
7768                memory_context::mint_explicit_command_proof(),
7769            )
7770            .expect_err("Off is not an enable mode");
7771        assert!(
7772            memory_context::drain_captured_memory_events_for_test().is_empty(),
7773            "failed enable must emit no event"
7774        );
7775
7776        runtime
7777            .memory_context_enable(
7778                memory_context::MemoryContextMode::CaptureAndRecall,
7779                memory_context::mint_explicit_command_proof(),
7780            )
7781            .expect("enable succeeds");
7782        runtime.memory_context_disable();
7783
7784        let events = memory_context::drain_captured_memory_events_for_test();
7785        assert_eq!(
7786            events
7787                .iter()
7788                .map(|event| (event.event, event.outcome))
7789                .collect::<Vec<_>>(),
7790            vec![
7791                ("memory_context.enabled", "enabled"),
7792                ("memory_context.disabled", "disabled"),
7793            ],
7794        );
7795        assert_eq!(events[0].mode, Some("capture_and_recall"));
7796        assert_eq!(events[1].mode, Some("off"));
7797
7798        // §15: the project identity in every event is the digest form,
7799        // never the raw working-directory path.
7800        let cwd = std::env::current_dir()
7801            .expect("cwd")
7802            .to_string_lossy()
7803            .into_owned();
7804        for event in &events {
7805            let json = serde_json::to_string(event).expect("event serializes");
7806            assert!(
7807                json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
7808                "digest identity expected; got: {json}"
7809            );
7810            assert!(
7811                !json.contains(&cwd),
7812                "raw project path leaked into event JSON: {json}"
7813            );
7814        }
7815    }
7816
7817    /// Task A6 session-end plumbing: dropping the LAST runtime owner runs
7818    /// the shared `ExtensionSessionEndGuard` (terminate_session — the SAME
7819    /// reap mechanism deferred tools/handlers use), and a fresh
7820    /// construction against the same extension runtime starts Off with no
7821    /// lease of any kind.
7822    #[tokio::test]
7823    async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
7824        let (runtime, ext) =
7825            memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7826        runtime
7827            .memory_context_enable(
7828                memory_context::MemoryContextMode::CaptureAndRecall,
7829                memory_context::mint_explicit_command_proof(),
7830            )
7831            .expect("enable succeeds");
7832        // True session end: the last owner drop fires the guard.
7833        drop(runtime);
7834        assert_eq!(
7835            ext.lease_count(),
7836            0,
7837            "session end must leave no runtime lease (never-spawned reap is a no-op)"
7838        );
7839
7840        // A fresh construction (what any new session does) is Off/no-lease.
7841        let mut fresh = Runtime::new_headless();
7842        fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
7843        assert_memory_off_no_lease(&fresh);
7844        assert_eq!(ext.lease_count(), 0);
7845    }
7846}