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