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