Skip to main content

agent_engine/runtime/
mod.rs

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