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