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