Skip to main content

talos_runtime/
lib.rs

1//! Embeddable Talos agent runtime facade.
2//!
3//! This crate is the SDK-style entrypoint for Rust projects that want to reuse
4//! Talos's agent turn loop without depending on the Talos CLI or TUI crates.
5
6#[cfg(feature = "shared-composition")]
7use std::collections::HashSet;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11#[cfg(feature = "shared-composition")]
12use crate::composition::SharedToolProfile;
13use async_trait::async_trait;
14use serde_json::Value;
15use talos_agent::permission_pipeline::{
16    ApprovalResolver, ApprovalResolverError, PermissionApprovalRequest,
17};
18use talos_agent::session::{AppServerSession, RuntimeAdmissionControl};
19use talos_agent::{Agent, AgentError, SandboxFallbackHandler};
20use talos_core::ApprovalChoice;
21use talos_core::message::Message;
22use talos_core::provider::LanguageModel;
23use talos_core::session::{
24    RuntimePolicy, SessionConfig, SessionEvent, SessionOp, TurnCompletionStatus,
25};
26use talos_core::tool::{AgentTool, ToolRegistry};
27#[cfg(test)]
28use talos_core::tool::{
29    ToolExecutionAuthorization, ToolExecutionOutput, ToolPermissionFacet, ToolResult,
30};
31use talos_permission::{
32    GrantPreview, GrantSource, InteractionCapability, PermissionContext, PermissionEngine,
33    PermissionMode, PermissionRule, PermissionSessionState,
34};
35#[cfg(test)]
36use talos_permission::{GrantScope, PermissionDecision, PermissionRequest};
37use talos_plugin::HookRegistry;
38use talos_sandbox::SandboxProvider;
39use talos_session::{DurableSession, PersistencePolicy};
40use talos_skill::SkillIndex;
41use thiserror::Error;
42use tokio::sync::mpsc;
43
44/// Shared language-provider host context for optional TUI and symbol consumers.
45#[cfg(feature = "language-provider")]
46pub struct LanguageProviderHost {
47    context: talos_text::SharedLanguageProvider,
48}
49
50#[cfg(feature = "language-provider")]
51impl Clone for LanguageProviderHost {
52    fn clone(&self) -> Self {
53        Self {
54            context: self.context.clone(),
55        }
56    }
57}
58
59#[cfg(feature = "language-provider")]
60impl LanguageProviderHost {
61    /// Construct a host from one loaded provider instance.
62    pub fn new(provider: Box<dyn talos_text::LanguageProviderBundle>) -> Self {
63        Self {
64            context: talos_text::SharedLanguageProvider::new(provider),
65        }
66    }
67
68    /// Access the shared provider context.
69    pub fn context(&self) -> &talos_text::SharedLanguageProvider {
70        &self.context
71    }
72
73    /// Clone the shared context for injection into a TUI or tool consumer.
74    pub fn shared_context(&self) -> talos_text::SharedLanguageProvider {
75        self.context.clone()
76    }
77
78    /// Execute a symbol operation against the same provider instance used for highlighting.
79    pub fn with_symbols<R>(
80        &self,
81        operation: impl FnOnce(&mut dyn talos_text::SymbolProvider) -> R,
82    ) -> Option<R> {
83        self.context.with_symbols(operation)
84    }
85}
86
87mod shutdown;
88
89pub use shutdown::{
90    ActiveTurnPolicy, RuntimeShutdownHandle, ShutdownActiveTurnOutcome, ShutdownActorOutcome,
91    ShutdownDurableOutcome, ShutdownFinalizerId, ShutdownFinalizerOutcome,
92    ShutdownFinalizerRegistryError, ShutdownFinalizerReport, ShutdownOptions, ShutdownOptionsError,
93    ShutdownPlanId, ShutdownReport,
94};
95use shutdown::{RuntimeFinalizer, RuntimeFinalizerRegistry, ShutdownCoordinator};
96
97struct BackgroundJobsRuntimeFinalizer {
98    handle: talos_agent::session::BackgroundJobFinalizerHandle,
99}
100
101impl RuntimeFinalizer for BackgroundJobsRuntimeFinalizer {
102    fn identifier(&self) -> ShutdownFinalizerId {
103        ShutdownFinalizerId::new("background_jobs")
104    }
105
106    fn order(&self) -> u16 {
107        100
108    }
109
110    fn cap(&self) -> std::time::Duration {
111        std::time::Duration::from_secs(3)
112    }
113
114    fn finalize(&self) -> shutdown::RuntimeFinalizerFuture {
115        let handle = self.handle.clone();
116        Box::pin(async move {
117            handle
118                .finalize()
119                .await
120                .map_err(|_| shutdown::RuntimeFinalizerError)
121        })
122    }
123}
124
125#[cfg(feature = "shared-composition")]
126#[doc(hidden)]
127pub mod composition;
128
129pub use talos_agent::evaluator::{
130    EvaluatorAdmission, EvaluatorAssessor, EvaluatorError, EvaluatorFailure, EvaluatorOutcome,
131    EvaluatorRequest, IndependentEvaluator, ProviderEvaluatorAssessor, ValidationEvidence,
132    ValidationEvidenceStatus,
133};
134pub use talos_agent::{SandboxFallbackContext, SandboxFallbackDecision, SandboxFallbackPolicy};
135pub use talos_core::message::{AgentEvent, MessageToolResult, StopReason, ToolCall, Usage};
136pub use talos_core::provider::{ProviderError, ToolDefinition};
137pub use talos_core::session::TurnCompletionStatus as RuntimeTurnCompletionStatus;
138pub use talos_core::tool::{ToolNature, ToolProvenance};
139pub use talos_plugin::HookRegistry as RuntimeHookRegistry;
140pub use talos_skill::SkillIndex as RuntimeSkillIndex;
141
142/// Explicit built-in capability preset for embedded runtimes.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum RuntimePreset {
145    /// The same shared coding capability composition used by the CLI.
146    Coding,
147}
148
149impl RuntimePreset {
150    /// Selects the explicit coding capability composition.
151    #[must_use]
152    pub const fn coding() -> Self {
153        Self::Coding
154    }
155}
156
157/// Errors returned by the embeddable runtime facade.
158#[derive(Debug, Error)]
159#[non_exhaustive]
160pub enum RuntimeError {
161    /// The builder cannot create a runtime without a provider.
162    #[error("runtime provider is required")]
163    MissingProvider,
164
165    /// A command could not be sent because the runtime actor is closed.
166    #[error("runtime command channel is closed")]
167    CommandChannelClosed,
168
169    /// A submission was rejected because the runtime shutdown fence has closed.
170    #[error("runtime is closing")]
171    RuntimeClosing,
172
173    /// Runtime construction requires an active Tokio runtime.
174    #[error("runtime construction requires an active Tokio runtime")]
175    AsyncRuntimeUnavailable,
176
177    /// The runtime actor task failed to join.
178    #[error("runtime actor failed: {0}")]
179    ActorJoin(#[from] tokio::task::JoinError),
180
181    /// The underlying agent returned an error.
182    #[error("agent error: {0}")]
183    Agent(#[from] AgentError),
184
185    /// The coding preset requires the optional shared-composition feature.
186    #[error("RuntimePreset::coding() requires the `shared-composition` feature")]
187    CodingPresetRequiresFeature,
188
189    /// A durable session could not be read or committed.
190    #[error("durable session error: {0}")]
191    Session(#[from] talos_session::SessionError),
192
193    /// The legacy shutdown wrapper observed incomplete bounded cleanup.
194    #[error("runtime shutdown did not complete")]
195    ShutdownIncomplete {
196        /// Redacted structured report describing the incomplete stages.
197        report: ShutdownReport,
198    },
199
200    /// Talos-owned shutdown finalizers could not be frozen safely.
201    #[error("invalid runtime shutdown finalizer registry: {0}")]
202    InvalidShutdownFinalizerRegistry(#[from] ShutdownFinalizerRegistryError),
203}
204
205/// Result alias for runtime facade operations.
206pub type RuntimeResult<T> = Result<T, RuntimeError>;
207
208/// Handles approval requests for permission-gated runtime tool calls.
209///
210/// Embedders can provide an implementation through
211/// [`RuntimeBuilder::approval_handler`] to bridge `Ask` decisions into their
212/// own UI, RPC, or policy layer. If no handler is configured, the runtime keeps
213/// the safe headless default and denies approval-gated calls.
214#[async_trait]
215pub trait ApprovalHandler: Send + Sync {
216    /// Requests a decision for a tool call whose permission policy returned
217    /// [`PermissionDecision::Ask`].
218    async fn request_approval(
219        &self,
220        tool_name: &str,
221        arguments: &Value,
222        summary_fields: &[String],
223    ) -> ApprovalChoice;
224
225    /// Requests approval using the exact bounded scope compiled by the
226    /// permission layer. Existing handlers remain source-compatible through
227    /// the projection-only default implementation.
228    async fn request_scoped_approval(
229        &self,
230        tool_name: &str,
231        arguments: &Value,
232        summary_fields: &[String],
233        _preview: &GrantPreview,
234    ) -> ApprovalChoice {
235        self.request_approval(tool_name, arguments, summary_fields)
236            .await
237    }
238
239    /// Requests a one-invocation approval to continue without sandbox
240    /// isolation. This is distinct from normal tool permission approval and
241    /// defaults to denial; `AlwaysApprove` is never accepted for fallback.
242    async fn request_sandbox_fallback(
243        &self,
244        _context: &SandboxFallbackContext,
245    ) -> SandboxFallbackDecision {
246        SandboxFallbackDecision::Deny
247    }
248}
249
250struct RuntimeSandboxFallbackHandler {
251    inner: Arc<dyn ApprovalHandler>,
252}
253
254struct RuntimeApprovalResolver {
255    inner: Arc<dyn ApprovalHandler>,
256}
257
258#[async_trait]
259impl ApprovalResolver for RuntimeApprovalResolver {
260    async fn resolve(
261        &self,
262        request: PermissionApprovalRequest,
263        _remaining: std::time::Duration,
264    ) -> Result<ApprovalChoice, ApprovalResolverError> {
265        Ok(self
266            .inner
267            .request_scoped_approval(
268                &request.tool_name,
269                &request.arguments,
270                &request.summary_fields,
271                &request.preview,
272            )
273            .await)
274    }
275
276    fn grant_source(&self) -> GrantSource {
277        GrantSource::SdkHostApproval
278    }
279}
280
281struct RuntimeActorExitGuard {
282    admission: RuntimeAdmissionControl,
283}
284
285impl Drop for RuntimeActorExitGuard {
286    fn drop(&mut self) {
287        self.admission.record_actor_stopped();
288    }
289}
290
291#[async_trait]
292impl SandboxFallbackHandler for RuntimeSandboxFallbackHandler {
293    async fn request_fallback(&self, context: SandboxFallbackContext) -> SandboxFallbackDecision {
294        self.inner.request_sandbox_fallback(&context).await
295    }
296}
297
298/// Builder for an embeddable Talos runtime.
299///
300/// The safe default is conservative: registered tools are wrapped in a
301/// permission-aware adapter, and unresolved `Ask` decisions are denied instead
302/// of being executed.
303pub struct RuntimeBuilder {
304    provider: Option<Arc<dyn LanguageModel>>,
305    tools: Vec<Arc<dyn AgentTool>>,
306    workspace_root: PathBuf,
307    permission_rules: Vec<PermissionRule>,
308    sandbox: Option<Box<dyn SandboxProvider>>,
309    sandbox_fallback_policy: SandboxFallbackPolicy,
310    preset: Option<RuntimePreset>,
311    initial_history: Vec<Message>,
312    model_context_limit: u32,
313    approval_handler: Option<Arc<dyn ApprovalHandler>>,
314    custom_prompt: Option<String>,
315    append_prompt: Option<String>,
316    hook_registry: Option<Arc<HookRegistry>>,
317    skill_index: Vec<SkillIndex>,
318    durable_session: Option<(DurableSession, PersistencePolicy)>,
319    shutdown_finalizers: Vec<Arc<dyn RuntimeFinalizer>>,
320    #[cfg(feature = "shared-composition")]
321    atomic_create_capability: Option<talos_core::tool::SharedAtomicCreateCapability>,
322}
323
324impl RuntimeBuilder {
325    /// Creates a builder with no provider and the current directory as the
326    /// workspace root.
327    #[must_use]
328    pub fn new() -> Self {
329        Self {
330            provider: None,
331            tools: Vec::new(),
332            workspace_root: PathBuf::from("."),
333            permission_rules: Vec::new(),
334            sandbox: None,
335            sandbox_fallback_policy: SandboxFallbackPolicy::Deny,
336            preset: None,
337            initial_history: Vec::new(),
338            model_context_limit: 128_000,
339            approval_handler: None,
340            custom_prompt: None,
341            append_prompt: None,
342            hook_registry: None,
343            skill_index: Vec::new(),
344            durable_session: None,
345            shutdown_finalizers: Vec::new(),
346            #[cfg(feature = "shared-composition")]
347            atomic_create_capability: None,
348        }
349    }
350
351    /// Sets the language model provider used by the runtime.
352    #[must_use]
353    pub fn provider(mut self, provider: Arc<dyn LanguageModel>) -> Self {
354        self.provider = Some(provider);
355        self
356    }
357
358    /// Sets the workspace root used for path-sensitive runtime behavior.
359    #[must_use]
360    pub fn workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
361        self.workspace_root = root.into();
362        self
363    }
364
365    /// Registers a tool with runtime-level permission gating.
366    #[must_use]
367    pub fn tool(mut self, tool: Arc<dyn AgentTool>) -> Self {
368        self.tools.push(tool);
369        self
370    }
371
372    /// Adds the explicit shared Talos tool composition to this runtime.
373    ///
374    /// This method is available only with the `shared-composition` feature. It
375    /// does not change `RuntimeBuilder::new()` and does not grant permission:
376    /// every added tool still passes through the runtime permission adapter.
377    #[cfg(feature = "shared-composition")]
378    #[must_use]
379    pub fn shared_tools(mut self) -> Self {
380        self.tools.extend(
381            composition::tool_contributions_with_capability(
382                SharedToolProfile::Runtime,
383                self.workspace_root.clone(),
384                self.atomic_create_capability.clone(),
385            )
386            .into_iter()
387            .map(|contribution| contribution.tool().clone()),
388        );
389        self
390    }
391
392    /// Supplies a held directory capability for atomic new-file creation in shared tools.
393    #[cfg(feature = "shared-composition")]
394    #[must_use]
395    pub fn atomic_create_capability(
396        mut self,
397        capability: talos_core::tool::SharedAtomicCreateCapability,
398    ) -> Self {
399        self.atomic_create_capability = Some(capability);
400        self
401    }
402
403    /// Adds an extra permission rule to the runtime permission engine.
404    ///
405    /// Runtime rules are evaluated before the engine's default fallback, so
406    /// embedders can add narrow allow-list or deny-list rules without changing
407    /// the safe default for unmatched write, execute, and network tools. Richer
408    /// policy import remains a later RUNTIME-001 follow-up.
409    #[must_use]
410    pub fn permission_rule(mut self, rule: PermissionRule) -> Self {
411        self.permission_rules.push(rule);
412        self
413    }
414
415    /// Sets an optional sandbox provider for sandbox-capable tools.
416    #[must_use]
417    pub fn sandbox(mut self, sandbox: Box<dyn SandboxProvider>) -> Self {
418        self.sandbox = Some(sandbox);
419        self
420    }
421
422    /// Sets the sandbox fallback policy. The default is `Deny`.
423    #[must_use]
424    pub fn sandbox_fallback_policy(mut self, policy: SandboxFallbackPolicy) -> Self {
425        self.sandbox_fallback_policy = policy;
426        self
427    }
428
429    /// Sets the sandbox fallback policy using the SDK contract name.
430    #[must_use]
431    pub fn sandbox_fallback(self, policy: SandboxFallbackPolicy) -> Self {
432        self.sandbox_fallback_policy(policy)
433    }
434
435    /// Selects an explicit runtime capability preset.
436    #[must_use]
437    pub fn preset(mut self, preset: RuntimePreset) -> Self {
438        self.preset = Some(preset);
439        self
440    }
441
442    /// Selects the shared coding capability composition.
443    #[must_use]
444    pub fn coding_preset(self) -> Self {
445        self.preset(RuntimePreset::coding())
446    }
447
448    /// Seeds the runtime with existing conversation history.
449    #[must_use]
450    pub fn initial_history(mut self, history: Vec<Message>) -> Self {
451        self.initial_history = history;
452        self
453    }
454
455    /// Sets the model context limit used by the session compactor.
456    #[must_use]
457    pub fn model_context_limit(mut self, limit: u32) -> Self {
458        self.model_context_limit = limit;
459        self
460    }
461
462    /// Sets the approval handler for tools whose permission policy returns
463    /// `Ask`.
464    ///
465    /// Without a handler, `Ask` decisions are denied. `AlwaysApprove` choices
466    /// install first-class in-memory Session grants for this runtime build
467    /// only; they are not persisted to user configuration or durable sessions.
468    #[must_use]
469    pub fn approval_handler(mut self, handler: Arc<dyn ApprovalHandler>) -> Self {
470        self.approval_handler = Some(handler);
471        self
472    }
473
474    /// Replaces the default Talos identity/system prompt.
475    ///
476    /// This is intended for embedders that reuse the runtime in a product with
477    /// its own identity. Use [`RuntimeBuilder::append_prompt`] when the default
478    /// identity should remain and only extra instructions are needed.
479    #[must_use]
480    pub fn custom_prompt(mut self, prompt: impl Into<String>) -> Self {
481        self.custom_prompt = Some(prompt.into());
482        self
483    }
484
485    /// Appends extra instructions to the system prompt.
486    #[must_use]
487    pub fn append_prompt(mut self, prompt: impl Into<String>) -> Self {
488        self.append_prompt = Some(prompt.into());
489        self
490    }
491
492    /// Binds this runtime to a host-selected durable Talos session.
493    ///
494    /// The session's model history is restored during [`Self::build`]. Every
495    /// successful turn is atomically persisted by Talos before its success
496    /// completion event is emitted; failed, cancelled, and denied turns are
497    /// not persisted. Calling this is optional and does not alter the existing
498    /// in-memory runtime behavior when omitted.
499    #[must_use]
500    pub fn durable_session(mut self, session: DurableSession) -> Self {
501        self.durable_session = Some((session, PersistencePolicy::default()));
502        self
503    }
504
505    /// Sets the filtering policy for a durable session binding.
506    #[must_use]
507    pub fn durable_session_with_policy(
508        mut self,
509        session: DurableSession,
510        policy: PersistencePolicy,
511    ) -> Self {
512        self.durable_session = Some((session, policy));
513        self
514    }
515
516    /// Injects a pre-populated [`HookRegistry`] into the runtime.
517    ///
518    /// This allows embedders to register reviewed hooks (e.g., from a curated
519    /// hook catalog) before the runtime starts, following the same builder
520    /// pattern as [`RuntimeBuilder::approval_handler`].
521    ///
522    /// If not called, the runtime uses an empty `HookRegistry`.
523    #[must_use]
524    pub fn hook_registry(mut self, registry: Arc<HookRegistry>) -> Self {
525        self.hook_registry = Some(registry);
526        self
527    }
528
529    /// Sets the skill index for the runtime's system prompt.
530    ///
531    /// Embedders that discover skills via [`talos_skill::SkillLoader`] (e.g.,
532    /// from a local or remote skill store) can inject the Level 0 index here,
533    /// following the same pattern as the CLI's `skill_runtime.rs`.
534    ///
535    /// If not called, the runtime has no skills in its system prompt.
536    #[must_use]
537    pub fn skill_index(mut self, skills: Vec<SkillIndex>) -> Self {
538        self.skill_index = skills;
539        self
540    }
541
542    #[cfg(test)]
543    fn runtime_finalizer(mut self, finalizer: Arc<dyn RuntimeFinalizer>) -> Self {
544        self.shutdown_finalizers.push(finalizer);
545        self
546    }
547
548    /// Builds and starts the runtime actor.
549    ///
550    /// The returned primary handle owns submission and event access. Dropping
551    /// it initiates the non-blocking default shutdown plan; use
552    /// [`RuntimeHandle::shutdown`] or [`RuntimeHandle::shutdown_with`] when the
553    /// host must observe terminal cleanup.
554    pub fn build(mut self) -> RuntimeResult<RuntimeHandle> {
555        let provider = self.provider.ok_or(RuntimeError::MissingProvider)?;
556        #[allow(unused_mut)]
557        let mut tools = self.tools;
558        if matches!(self.preset, Some(RuntimePreset::Coding)) {
559            #[cfg(feature = "shared-composition")]
560            {
561                // Explicit caller tools are authoritative. A preset must not
562                // replace an embedder's hardened implementation by name.
563                let caller_tool_names = tools
564                    .iter()
565                    .map(|tool| tool.name().to_owned())
566                    .collect::<HashSet<_>>();
567                tools.extend(
568                    composition::runtime_tool_contributions(self.workspace_root.clone())
569                        .into_iter()
570                        .filter(|contribution| !caller_tool_names.contains(contribution.name()))
571                        .map(|contribution| contribution.tool().clone()),
572                );
573            }
574            #[cfg(not(feature = "shared-composition"))]
575            return Err(RuntimeError::CodingPresetRequiresFeature);
576        }
577        let agent_engine = Arc::new(build_permission_engine(
578            self.workspace_root.clone(),
579            &self.permission_rules,
580        ));
581        let mut registry = ToolRegistry::new();
582        let approval_handler = self.approval_handler.clone();
583        for tool in tools {
584            registry.register(tool);
585        }
586
587        let fallback_handler = self.approval_handler.as_ref().map(|handler| {
588            Arc::new(RuntimeSandboxFallbackHandler {
589                inner: handler.clone(),
590            }) as Arc<dyn SandboxFallbackHandler>
591        });
592        let mut agent = if let Some(hooks) = self.hook_registry {
593            Agent::with_security_and_hooks_and_sandbox_fallback(
594                provider,
595                registry,
596                None,
597                self.sandbox,
598                self.workspace_root.clone(),
599                hooks,
600                self.sandbox_fallback_policy,
601                fallback_handler,
602            )
603        } else {
604            Agent::with_security_and_sandbox_fallback(
605                provider,
606                registry,
607                None,
608                self.sandbox,
609                self.workspace_root.clone(),
610                self.sandbox_fallback_policy,
611                fallback_handler,
612            )
613        };
614        let resolver = approval_handler.map(|handler| {
615            Arc::new(RuntimeApprovalResolver { inner: handler }) as Arc<dyn ApprovalResolver>
616        });
617        agent = agent.with_permission_pipeline(
618            Arc::new(PermissionSessionState::new((*agent_engine).clone())),
619            PermissionContext::new(
620                PermissionMode::Headless,
621                if resolver.is_some() {
622                    InteractionCapability::Available
623                } else {
624                    InteractionCapability::Unavailable
625                },
626            ),
627            resolver,
628        );
629        if let Some(prompt) = self.custom_prompt {
630            agent.set_custom_prompt(prompt);
631        }
632        if let Some(prompt) = self.append_prompt {
633            agent.set_append_prompt(prompt);
634        }
635        if !self.skill_index.is_empty() {
636            agent.set_skill_index(self.skill_index);
637        }
638        let initial_history = if let Some((session, _)) = &self.durable_session {
639            session.read_messages()?
640        } else {
641            self.initial_history
642        };
643        let config = SessionConfig {
644            runtime_policy: RuntimePolicy::headless_deny(),
645            workspace_root: self.workspace_root,
646            initial_history,
647            model_context_limit: self.model_context_limit,
648        };
649        let (handle, mut actor) = AppServerSession::new(agent, config);
650        self.shutdown_finalizers
651            .push(Arc::new(BackgroundJobsRuntimeFinalizer {
652                handle: actor.background_job_finalizer(),
653            }));
654        let finalizers = RuntimeFinalizerRegistry::freeze(self.shutdown_finalizers)?;
655        let admission = talos_agent::session::RuntimeAdmissionControl::new();
656        actor.set_runtime_admission(admission.clone());
657        if let Some((session, policy)) = self.durable_session {
658            actor.set_durable_persistence(session, policy);
659        }
660        let runtime = tokio::runtime::Handle::try_current()
661            .map_err(|_| RuntimeError::AsyncRuntimeUnavailable)?;
662        let actor_exit_guard = RuntimeActorExitGuard {
663            admission: admission.clone(),
664        };
665        let actor_task = runtime.spawn(async move {
666            let _actor_exit_guard = actor_exit_guard;
667            actor.run().await;
668        });
669        let coordinator = ShutdownCoordinator::new(
670            admission,
671            handle.sq_tx.clone(),
672            actor_task,
673            runtime,
674            finalizers,
675        );
676
677        Ok(RuntimeHandle {
678            command_tx: handle.sq_tx,
679            event_rx: handle.eq_rx,
680            coordinator,
681            primary_drop_armed: true,
682        })
683    }
684}
685
686impl Default for RuntimeBuilder {
687    fn default() -> Self {
688        Self::new()
689    }
690}
691
692/// Handle for interacting with a running embedded Talos runtime.
693pub struct RuntimeHandle {
694    command_tx: mpsc::Sender<SessionOp>,
695    event_rx: mpsc::UnboundedReceiver<SessionEvent>,
696    coordinator: Arc<ShutdownCoordinator>,
697    primary_drop_armed: bool,
698}
699
700impl RuntimeHandle {
701    /// Submits a user message as a new turn.
702    pub async fn submit(&self, message: impl Into<String>) -> RuntimeResult<()> {
703        if !self.coordinator.is_admission_open() {
704            return Err(RuntimeError::RuntimeClosing);
705        }
706        let permit = self.command_tx.reserve().await.map_err(|_| {
707            if self.coordinator.is_admission_open() {
708                RuntimeError::CommandChannelClosed
709            } else {
710                RuntimeError::RuntimeClosing
711            }
712        })?;
713        self.coordinator
714            .commit_reserved(
715                permit,
716                SessionOp::Submit {
717                    message: message.into(),
718                },
719            )
720            .map_err(|_| RuntimeError::RuntimeClosing)
721    }
722
723    /// Requests a provider request preview without making a provider call.
724    pub async fn preview_request(&self, message: impl Into<String>) -> RuntimeResult<()> {
725        if !self.coordinator.is_admission_open() {
726            return Err(RuntimeError::RuntimeClosing);
727        }
728        let permit = self.command_tx.reserve().await.map_err(|_| {
729            if self.coordinator.is_admission_open() {
730                RuntimeError::CommandChannelClosed
731            } else {
732                RuntimeError::RuntimeClosing
733            }
734        })?;
735        self.coordinator
736            .commit_reserved(
737                permit,
738                SessionOp::PreviewRequest {
739                    message: message.into(),
740                },
741            )
742            .map_err(|_| RuntimeError::RuntimeClosing)
743    }
744
745    /// Interrupts the active turn, if any.
746    pub async fn interrupt(&self) -> RuntimeResult<()> {
747        self.command_tx
748            .send(SessionOp::Interrupt)
749            .await
750            .map_err(|_| RuntimeError::CommandChannelClosed)
751    }
752
753    /// Receives the next runtime event.
754    pub async fn next_event(&mut self) -> Option<SessionEvent> {
755        self.event_rx.recv().await
756    }
757
758    /// Returns a cloneable controller that can only initiate or join shutdown.
759    #[must_use]
760    pub fn shutdown_controller(&self) -> RuntimeShutdownHandle {
761        RuntimeShutdownHandle {
762            coordinator: self.coordinator.clone(),
763        }
764    }
765
766    /// Starts or joins structured bounded shutdown without consuming the handle.
767    pub async fn shutdown_with(&self, options: ShutdownOptions) -> RuntimeResult<ShutdownReport> {
768        self.coordinator.shutdown(options).await
769    }
770
771    /// Shuts down the runtime actor and waits for it to finish.
772    pub async fn shutdown(mut self) -> RuntimeResult<()> {
773        self.primary_drop_armed = false;
774        let report = self
775            .coordinator
776            .shutdown(ShutdownOptions::legacy_default())
777            .await?;
778        if let Some(error) = self.coordinator.take_actor_join_error() {
779            return Err(RuntimeError::ActorJoin(error));
780        }
781        if report.is_complete() {
782            Ok(())
783        } else {
784            Err(RuntimeError::ShutdownIncomplete { report })
785        }
786    }
787}
788
789impl Drop for RuntimeHandle {
790    fn drop(&mut self) {
791        if self.primary_drop_armed {
792            self.primary_drop_armed = false;
793            self.coordinator.initiate_default();
794        }
795    }
796}
797
798fn build_permission_engine(root: PathBuf, rules: &[PermissionRule]) -> PermissionEngine {
799    let mut engine = PermissionEngine::from_rules(rules.to_vec());
800    engine.set_workspace_root(root);
801    engine
802}
803
804#[cfg(test)]
805struct RuntimePermissionAwareTool {
806    inner: Arc<dyn AgentTool>,
807    permission_state: Arc<PermissionSessionState>,
808    approval_handler: Option<Arc<dyn ApprovalHandler>>,
809}
810
811#[cfg(test)]
812impl RuntimePermissionAwareTool {
813    async fn authorize(
814        &self,
815        input: &Value,
816        profile: &[ToolPermissionFacet],
817    ) -> Result<Vec<ToolExecutionAuthorization>, ToolResult> {
818        let interaction = if self.approval_handler.is_some() {
819            InteractionCapability::Available
820        } else {
821            InteractionCapability::Unavailable
822        };
823        let context = PermissionContext::new(PermissionMode::Headless, interaction);
824        let provenance = self.inner.provenance();
825        let request = PermissionRequest::new(self.inner.name(), provenance, profile, input);
826        let evaluation = self
827            .permission_state
828            .evaluate(&request, &context)
829            .map_err(permission_error)?;
830
831        let pending = match evaluation.decision() {
832            PermissionDecision::Allow => self
833                .permission_state
834                .prepare_authorized(&request, &context)
835                .map_err(permission_error)?
836                .ok_or_else(|| permission_denied("request is not authorized"))?,
837            PermissionDecision::Deny(reason) => {
838                return Err(permission_denied(&reason));
839            }
840            PermissionDecision::Ask => {
841                let Some(handler) = &self.approval_handler else {
842                    return Err(permission_denied(
843                        "approval required but no runtime approval handler is configured",
844                    ));
845                };
846                let once = self
847                    .permission_state
848                    .propose(&request, &context, GrantScope::Once)
849                    .map_err(permission_error)?;
850                let session = self
851                    .permission_state
852                    .propose(&request, &context, GrantScope::Session)
853                    .map_err(permission_error)?;
854                let summary_fields = self
855                    .inner
856                    .summary_fields()
857                    .iter()
858                    .map(|field| (*field).to_string())
859                    .collect::<Vec<_>>();
860                match handler
861                    .request_scoped_approval(
862                        self.inner.name(),
863                        &self.inner.project_input(input),
864                        &summary_fields,
865                        session.preview(),
866                    )
867                    .await
868                {
869                    ApprovalChoice::ApproveOnce => self
870                        .permission_state
871                        .approve_once(once, &request, &context)
872                        .map_err(permission_error)?,
873                    ApprovalChoice::AlwaysApprove => self
874                        .permission_state
875                        .approve_session(session, &request, &context, GrantSource::SdkHostApproval)
876                        .map_err(permission_error)?,
877                    ApprovalChoice::Deny => return Err(permission_denied("User denied")),
878                }
879            }
880        };
881
882        self.permission_state
883            .admit(pending, &request, &context)
884            .map_err(permission_error)
885    }
886}
887
888#[cfg(test)]
889fn permission_error(error: impl std::fmt::Display) -> ToolResult {
890    permission_denied(&error.to_string())
891}
892
893#[cfg(test)]
894fn permission_denied(reason: &str) -> ToolResult {
895    ToolResult::error(format!("Permission denied: {reason}"))
896}
897
898#[async_trait]
899#[cfg(test)]
900impl AgentTool for RuntimePermissionAwareTool {
901    fn name(&self) -> &str {
902        self.inner.name()
903    }
904
905    fn description(&self) -> &str {
906        self.inner.description()
907    }
908
909    fn parameters(&self) -> Value {
910        self.inner.parameters()
911    }
912
913    async fn execute(&self, input: Value) -> ToolResult {
914        let profile = self.inner.permission_profile(&input);
915        let authorizations = match self.authorize(&input, &profile).await {
916            Ok(authorizations) => authorizations,
917            Err(error) => return error,
918        };
919        self.inner.execute_authorized(input, &authorizations).await
920    }
921
922    async fn execute_with_output(&self, input: Value) -> ToolExecutionOutput {
923        let profile = self.inner.permission_profile(&input);
924        let authorizations = match self.authorize(&input, &profile).await {
925            Ok(authorizations) => authorizations,
926            Err(error) => return ToolExecutionOutput::from_result(error),
927        };
928        self.inner
929            .execute_authorized_with_output(input, &authorizations)
930            .await
931    }
932
933    fn is_read_only(&self) -> bool {
934        self.inner.is_read_only()
935    }
936
937    fn nature(&self) -> talos_core::tool::ToolNature {
938        self.inner.nature()
939    }
940
941    fn family(&self) -> talos_core::tool::ToolFamily {
942        self.inner.family()
943    }
944
945    fn is_always_on(&self) -> bool {
946        self.inner.is_always_on()
947    }
948
949    fn permission_profile(&self, input: &Value) -> Vec<talos_core::tool::ToolPermissionFacet> {
950        self.inner.permission_profile(input)
951    }
952
953    fn summary_fields(&self) -> &'static [&'static str] {
954        self.inner.summary_fields()
955    }
956
957    fn project_input(&self, input: &Value) -> Value {
958        self.inner.project_input(input)
959    }
960
961    fn project_result(&self, result: &ToolResult) -> talos_core::tool::ToolResultProjection {
962        self.inner.project_result(result)
963    }
964
965    fn provenance(&self) -> talos_core::tool::ToolProvenance {
966        self.inner.provenance()
967    }
968}
969
970/// Collects events until the current turn completes.
971///
972/// This helper is intended for embedders that want a simple per-turn API on top
973/// of the streaming event channel.
974pub async fn collect_until_turn_completed(
975    runtime: &mut RuntimeHandle,
976) -> Option<TurnCompletionStatus> {
977    while let Some(event) = runtime.next_event().await {
978        if let SessionEvent::TurnEvent {
979            payload: talos_core::session::TurnEventPayload::Completed { status },
980            ..
981        } = event
982        {
983            return Some(status);
984        }
985    }
986    None
987}
988
989#[cfg(test)]
990mod tests {
991    use std::sync::Mutex as StdMutex;
992    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
993    use std::time::Duration;
994
995    use talos_core::message::Message;
996    use talos_core::provider::ProviderResult;
997    use talos_core::tool::{ToolNature, ToolPermissionFacet, ToolResourceKind};
998    use talos_permission::PermissionDecision;
999    use talos_provider::mock::MockProvider;
1000    use talos_session::SessionManager;
1001    use talos_tools::{ReadTool, snapshot_aware_file_tools};
1002    use tokio::sync::Notify;
1003
1004    use super::*;
1005
1006    struct RecordingWriteTool {
1007        executions: Arc<AtomicUsize>,
1008    }
1009
1010    struct RecordingHybridTool {
1011        executions: Arc<AtomicUsize>,
1012    }
1013
1014    struct PrivateInputWriteTool;
1015
1016    struct PrivateResultReadTool;
1017
1018    struct GatedModel {
1019        entered: Arc<Notify>,
1020        release: Arc<Notify>,
1021    }
1022
1023    #[derive(Clone)]
1024    enum TestFinalizerBehavior {
1025        Complete,
1026        Fail,
1027        Panic,
1028        Pending(Arc<AtomicBool>),
1029    }
1030
1031    struct TestFinalizer {
1032        identifier: ShutdownFinalizerId,
1033        order: u16,
1034        cap: Duration,
1035        behavior: TestFinalizerBehavior,
1036        starts: Arc<StdMutex<Vec<&'static str>>>,
1037    }
1038
1039    impl RuntimeFinalizer for TestFinalizer {
1040        fn identifier(&self) -> ShutdownFinalizerId {
1041            self.identifier
1042        }
1043
1044        fn order(&self) -> u16 {
1045            self.order
1046        }
1047
1048        fn cap(&self) -> Duration {
1049            self.cap
1050        }
1051
1052        fn finalize(&self) -> shutdown::RuntimeFinalizerFuture {
1053            let identifier = self.identifier.as_str();
1054            let starts = self.starts.clone();
1055            let behavior = self.behavior.clone();
1056            Box::pin(async move {
1057                starts
1058                    .lock()
1059                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1060                    .push(identifier);
1061                match behavior {
1062                    TestFinalizerBehavior::Complete => Ok(()),
1063                    TestFinalizerBehavior::Fail => Err(shutdown::RuntimeFinalizerError),
1064                    TestFinalizerBehavior::Panic => panic!("intentional runtime finalizer panic"),
1065                    TestFinalizerBehavior::Pending(cancelled) => {
1066                        struct CancellationMarker(Arc<AtomicBool>);
1067                        impl Drop for CancellationMarker {
1068                            fn drop(&mut self) {
1069                                self.0.store(true, Ordering::SeqCst);
1070                            }
1071                        }
1072                        let _marker = CancellationMarker(cancelled);
1073                        std::future::pending::<()>().await;
1074                        Ok(())
1075                    }
1076                }
1077            })
1078        }
1079    }
1080
1081    fn test_finalizer(
1082        identifier: &'static str,
1083        order: u16,
1084        cap: Duration,
1085        behavior: TestFinalizerBehavior,
1086        starts: Arc<StdMutex<Vec<&'static str>>>,
1087    ) -> Arc<dyn RuntimeFinalizer> {
1088        Arc::new(TestFinalizer {
1089            identifier: ShutdownFinalizerId::new(identifier),
1090            order,
1091            cap,
1092            behavior,
1093            starts,
1094        })
1095    }
1096
1097    #[async_trait]
1098    impl LanguageModel for GatedModel {
1099        async fn stream(
1100            &self,
1101            _messages: &[Message],
1102        ) -> ProviderResult<mpsc::Receiver<AgentEvent>> {
1103            let (tx, rx) = mpsc::channel(8);
1104            let release = self.release.clone();
1105            self.entered.notify_one();
1106            tokio::spawn(async move {
1107                release.notified().await;
1108                let events = [
1109                    AgentEvent::TurnStart,
1110                    AgentEvent::TextDelta {
1111                        delta: "finished".into(),
1112                    },
1113                    AgentEvent::TurnEnd {
1114                        stop_reason: StopReason::EndTurn,
1115                        usage: Usage::default(),
1116                    },
1117                ];
1118                for event in events {
1119                    if tx.send(event).await.is_err() {
1120                        break;
1121                    }
1122                }
1123            });
1124            Ok(rx)
1125        }
1126    }
1127
1128    async fn gated_runtime() -> (RuntimeHandle, Arc<Notify>, Arc<Notify>) {
1129        let entered = Arc::new(Notify::new());
1130        let release = Arc::new(Notify::new());
1131        let runtime = RuntimeBuilder::new()
1132            .provider(Arc::new(GatedModel {
1133                entered: entered.clone(),
1134                release: release.clone(),
1135            }))
1136            .build()
1137            .expect("gated runtime builds");
1138        (runtime, entered, release)
1139    }
1140
1141    #[test]
1142    fn shutdown_options_reject_invalid_drafts_before_runtime_access() {
1143        assert_eq!(
1144            ShutdownOptions::interrupt(Duration::ZERO),
1145            Err(ShutdownOptionsError::ZeroTotalTimeout)
1146        );
1147        assert_eq!(
1148            ShutdownOptions::finish_current(Duration::from_secs(1), Duration::from_secs(1)),
1149            Err(ShutdownOptionsError::FinishGraceNotLessThanTotal)
1150        );
1151        assert_eq!(
1152            ShutdownOptions::interrupt(Duration::MAX),
1153            Err(ShutdownOptionsError::TotalTimeoutOutOfRange)
1154        );
1155    }
1156
1157    #[test]
1158    fn runtime_build_freezes_and_validates_finalizer_identity_and_order() {
1159        let starts = Arc::new(StdMutex::new(Vec::new()));
1160        let duplicate_identifier = RuntimeBuilder::new()
1161            .provider(Arc::new(MockProvider::new()))
1162            .runtime_finalizer(test_finalizer(
1163                "test.duplicate",
1164                10,
1165                Duration::from_secs(1),
1166                TestFinalizerBehavior::Complete,
1167                starts.clone(),
1168            ))
1169            .runtime_finalizer(test_finalizer(
1170                "test.duplicate",
1171                20,
1172                Duration::from_secs(1),
1173                TestFinalizerBehavior::Complete,
1174                starts.clone(),
1175            ))
1176            .build();
1177        assert!(matches!(
1178            duplicate_identifier,
1179            Err(RuntimeError::InvalidShutdownFinalizerRegistry(
1180                ShutdownFinalizerRegistryError::DuplicateIdentifier
1181            ))
1182        ));
1183
1184        let duplicate_order = RuntimeBuilder::new()
1185            .provider(Arc::new(MockProvider::new()))
1186            .runtime_finalizer(test_finalizer(
1187                "test.first",
1188                10,
1189                Duration::from_secs(1),
1190                TestFinalizerBehavior::Complete,
1191                starts.clone(),
1192            ))
1193            .runtime_finalizer(test_finalizer(
1194                "test.second",
1195                10,
1196                Duration::from_secs(1),
1197                TestFinalizerBehavior::Complete,
1198                starts.clone(),
1199            ))
1200            .build();
1201        assert!(matches!(
1202            duplicate_order,
1203            Err(RuntimeError::InvalidShutdownFinalizerRegistry(
1204                ShutdownFinalizerRegistryError::DuplicateOrder
1205            ))
1206        ));
1207
1208        let zero_cap = RuntimeBuilder::new()
1209            .provider(Arc::new(MockProvider::new()))
1210            .runtime_finalizer(test_finalizer(
1211                "test.zero-cap",
1212                10,
1213                Duration::ZERO,
1214                TestFinalizerBehavior::Complete,
1215                starts,
1216            ))
1217            .build();
1218        assert!(matches!(
1219            zero_cap,
1220            Err(RuntimeError::InvalidShutdownFinalizerRegistry(
1221                ShutdownFinalizerRegistryError::ZeroCap
1222            ))
1223        ));
1224    }
1225
1226    #[tokio::test]
1227    async fn durable_reconciliation_precedes_ordered_finalizers() {
1228        let admission = talos_agent::session::RuntimeAdmissionControl::new();
1229        let actor_admission = admission.clone();
1230        let (command_tx, mut command_rx) = mpsc::channel(1);
1231        let order = Arc::new(StdMutex::new(Vec::new()));
1232        let actor_order = order.clone();
1233        let actor_task = tokio::spawn(async move {
1234            assert!(matches!(command_rx.recv().await, Some(SessionOp::Shutdown)));
1235            actor_order
1236                .lock()
1237                .unwrap_or_else(std::sync::PoisonError::into_inner)
1238                .push("durable");
1239            actor_admission.record_reconciliation(0, true);
1240        });
1241        let registry = RuntimeFinalizerRegistry::freeze(vec![test_finalizer(
1242            "test.finalizer",
1243            10,
1244            Duration::from_secs(1),
1245            TestFinalizerBehavior::Complete,
1246            order.clone(),
1247        )])
1248        .expect("registry is valid");
1249        let coordinator = ShutdownCoordinator::new(
1250            admission,
1251            command_tx,
1252            actor_task,
1253            tokio::runtime::Handle::current(),
1254            registry,
1255        );
1256
1257        let report = coordinator
1258            .shutdown(ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"))
1259            .await
1260            .expect("shutdown report");
1261
1262        assert_eq!(
1263            *order
1264                .lock()
1265                .unwrap_or_else(std::sync::PoisonError::into_inner),
1266            vec!["durable", "test.finalizer"]
1267        );
1268        assert_eq!(report.finalizers().len(), 1);
1269        assert_eq!(
1270            report.finalizers()[0].identifier(),
1271            ShutdownFinalizerId::new("test.finalizer")
1272        );
1273        assert_eq!(
1274            report.finalizers()[0].outcome(),
1275            ShutdownFinalizerOutcome::Completed
1276        );
1277        assert!(report.is_complete());
1278    }
1279
1280    #[tokio::test]
1281    async fn finalizer_failure_and_panic_are_typed_and_do_not_stop_later_entries() {
1282        let starts = Arc::new(StdMutex::new(Vec::new()));
1283        let runtime = RuntimeBuilder::new()
1284            .provider(Arc::new(MockProvider::new()))
1285            .runtime_finalizer(test_finalizer(
1286                "test.third",
1287                30,
1288                Duration::from_secs(1),
1289                TestFinalizerBehavior::Complete,
1290                starts.clone(),
1291            ))
1292            .runtime_finalizer(test_finalizer(
1293                "test.first",
1294                10,
1295                Duration::from_secs(1),
1296                TestFinalizerBehavior::Fail,
1297                starts.clone(),
1298            ))
1299            .runtime_finalizer(test_finalizer(
1300                "test.second",
1301                20,
1302                Duration::from_secs(1),
1303                TestFinalizerBehavior::Panic,
1304                starts.clone(),
1305            ))
1306            .build()
1307            .expect("runtime builds");
1308
1309        let report = runtime
1310            .shutdown_with(
1311                ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1312            )
1313            .await
1314            .expect("structured report");
1315
1316        assert_eq!(
1317            *starts
1318                .lock()
1319                .unwrap_or_else(std::sync::PoisonError::into_inner),
1320            vec!["test.first", "test.second", "test.third"]
1321        );
1322        assert_eq!(
1323            report
1324                .finalizers()
1325                .iter()
1326                .map(ShutdownFinalizerReport::outcome)
1327                .collect::<Vec<_>>(),
1328            vec![
1329                ShutdownFinalizerOutcome::Failed,
1330                ShutdownFinalizerOutcome::Panicked,
1331                ShutdownFinalizerOutcome::Completed,
1332                ShutdownFinalizerOutcome::Completed,
1333            ]
1334        );
1335        assert!(!report.is_complete());
1336        assert!(matches!(
1337            runtime.shutdown().await,
1338            Err(RuntimeError::ShutdownIncomplete { .. })
1339        ));
1340        assert_eq!(
1341            *starts
1342                .lock()
1343                .unwrap_or_else(std::sync::PoisonError::into_inner),
1344            vec!["test.first", "test.second", "test.third"]
1345        );
1346    }
1347
1348    #[tokio::test]
1349    async fn finalizer_cap_contains_timeout_and_allows_later_entry() {
1350        let starts = Arc::new(StdMutex::new(Vec::new()));
1351        let cancelled = Arc::new(AtomicBool::new(false));
1352        let runtime = RuntimeBuilder::new()
1353            .provider(Arc::new(MockProvider::new()))
1354            .runtime_finalizer(test_finalizer(
1355                "test.timeout",
1356                10,
1357                Duration::from_millis(20),
1358                TestFinalizerBehavior::Pending(cancelled.clone()),
1359                starts.clone(),
1360            ))
1361            .runtime_finalizer(test_finalizer(
1362                "test.after-timeout",
1363                20,
1364                Duration::from_secs(1),
1365                TestFinalizerBehavior::Complete,
1366                starts.clone(),
1367            ))
1368            .build()
1369            .expect("runtime builds");
1370
1371        let report = runtime
1372            .shutdown_with(
1373                ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1374            )
1375            .await
1376            .expect("structured report");
1377        tokio::task::yield_now().await;
1378
1379        assert!(cancelled.load(Ordering::SeqCst));
1380        assert_eq!(
1381            *starts
1382                .lock()
1383                .unwrap_or_else(std::sync::PoisonError::into_inner),
1384            vec!["test.timeout", "test.after-timeout"]
1385        );
1386        assert_eq!(
1387            report
1388                .finalizers()
1389                .iter()
1390                .map(ShutdownFinalizerReport::outcome)
1391                .collect::<Vec<_>>(),
1392            vec![
1393                ShutdownFinalizerOutcome::TimedOut,
1394                ShutdownFinalizerOutcome::Completed,
1395                ShutdownFinalizerOutcome::Completed,
1396            ]
1397        );
1398        assert!(!report.deadline_exhausted());
1399    }
1400
1401    #[tokio::test]
1402    async fn invalid_options_leave_the_primary_runtime_usable() {
1403        let mut runtime = RuntimeBuilder::new()
1404            .provider(Arc::new(MockProvider::new().with_response("still open")))
1405            .build()
1406            .expect("runtime builds");
1407        assert!(ShutdownOptions::interrupt(Duration::ZERO).is_err());
1408
1409        runtime
1410            .submit("continue")
1411            .await
1412            .expect("submit still succeeds");
1413        assert!(matches!(
1414            collect_until_turn_completed(&mut runtime).await,
1415            Some(TurnCompletionStatus::Success { .. })
1416        ));
1417        runtime.shutdown().await.expect("shutdown succeeds");
1418    }
1419
1420    #[tokio::test]
1421    async fn concurrent_shutdown_callers_share_one_cached_report() {
1422        let runtime = RuntimeBuilder::new()
1423            .provider(Arc::new(MockProvider::new().with_response("unused")))
1424            .build()
1425            .expect("runtime builds");
1426        let first = runtime.shutdown_controller();
1427        let second = first.clone();
1428        let first_task = tokio::spawn(async move {
1429            first
1430                .shutdown(
1431                    ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1432                )
1433                .await
1434        });
1435        let second_task = tokio::spawn(async move {
1436            second
1437                .shutdown(
1438                    ShutdownOptions::finish_current(
1439                        Duration::from_secs(2),
1440                        Duration::from_millis(10),
1441                    )
1442                    .expect("valid options"),
1443                )
1444                .await
1445        });
1446        let first_report = first_task.await.expect("caller joins").expect("report");
1447        let second_report = second_task.await.expect("caller joins").expect("report");
1448
1449        assert_eq!(first_report, second_report);
1450        assert!(first_report.is_complete());
1451        assert_eq!(first_report.active_turn(), ShutdownActiveTurnOutcome::Idle);
1452        runtime
1453            .shutdown()
1454            .await
1455            .expect("legacy caller joins cached result");
1456    }
1457
1458    #[tokio::test]
1459    async fn interrupt_closes_admission_and_finalizes_the_active_turn() {
1460        let (runtime, entered, _release) = gated_runtime().await;
1461        let entered_wait = entered.notified();
1462        runtime.submit("block").await.expect("submit succeeds");
1463        entered_wait.await;
1464
1465        let report = runtime
1466            .shutdown_with(
1467                ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1468            )
1469            .await
1470            .expect("structured report");
1471        assert_eq!(
1472            report.active_turn(),
1473            ShutdownActiveTurnOutcome::InterruptedAndFinalized
1474        );
1475        assert!(report.is_complete());
1476        assert!(matches!(
1477            runtime.submit("too late").await,
1478            Err(RuntimeError::RuntimeClosing)
1479        ));
1480        runtime
1481            .shutdown()
1482            .await
1483            .expect("legacy wrapper joins report");
1484    }
1485
1486    #[tokio::test]
1487    async fn finish_current_uses_grace_without_starting_post_fence_work() {
1488        let (runtime, entered, release) = gated_runtime().await;
1489        let entered_wait = entered.notified();
1490        runtime.submit("block").await.expect("submit succeeds");
1491        entered_wait.await;
1492        let controller = runtime.shutdown_controller();
1493        let shutdown = tokio::spawn(async move {
1494            controller
1495                .shutdown(
1496                    ShutdownOptions::finish_current(
1497                        Duration::from_secs(1),
1498                        Duration::from_millis(500),
1499                    )
1500                    .expect("valid options"),
1501                )
1502                .await
1503        });
1504        tokio::task::yield_now().await;
1505        assert!(matches!(
1506            runtime.submit("post-fence").await,
1507            Err(RuntimeError::RuntimeClosing)
1508        ));
1509        release.notify_one();
1510        let report = shutdown.await.expect("caller joins").expect("report");
1511
1512        assert_eq!(report.active_turn(), ShutdownActiveTurnOutcome::Finished);
1513        assert!(report.is_complete());
1514        runtime
1515            .shutdown()
1516            .await
1517            .expect("legacy wrapper joins report");
1518    }
1519
1520    #[tokio::test]
1521    async fn finish_current_grace_expiry_uses_actor_owned_interrupt() {
1522        let (runtime, entered, _release) = gated_runtime().await;
1523        let entered_wait = entered.notified();
1524        runtime.submit("block").await.expect("submit succeeds");
1525        entered_wait.await;
1526
1527        let report = runtime
1528            .shutdown_with(
1529                ShutdownOptions::finish_current(Duration::from_secs(1), Duration::from_millis(10))
1530                    .expect("valid options"),
1531            )
1532            .await
1533            .expect("structured report");
1534        assert_eq!(
1535            report.active_turn(),
1536            ShutdownActiveTurnOutcome::InterruptedAndFinalized
1537        );
1538        assert!(report.is_complete());
1539        runtime
1540            .shutdown()
1541            .await
1542            .expect("legacy wrapper joins report");
1543    }
1544
1545    #[tokio::test]
1546    async fn finish_current_never_starts_pre_fence_pending_work() {
1547        let entered = Arc::new(Notify::new());
1548        let release = Arc::new(Notify::new());
1549        let calls = Arc::new(AtomicUsize::new(0));
1550        struct CountingGatedModel {
1551            entered: Arc<Notify>,
1552            release: Arc<Notify>,
1553            calls: Arc<AtomicUsize>,
1554        }
1555        #[async_trait]
1556        impl LanguageModel for CountingGatedModel {
1557            async fn stream(
1558                &self,
1559                _messages: &[Message],
1560            ) -> ProviderResult<mpsc::Receiver<AgentEvent>> {
1561                self.calls.fetch_add(1, Ordering::SeqCst);
1562                let (tx, rx) = mpsc::channel(8);
1563                let release = self.release.clone();
1564                self.entered.notify_one();
1565                tokio::spawn(async move {
1566                    release.notified().await;
1567                    for event in [
1568                        AgentEvent::TurnStart,
1569                        AgentEvent::TurnEnd {
1570                            stop_reason: StopReason::EndTurn,
1571                            usage: Usage::default(),
1572                        },
1573                    ] {
1574                        let _ = tx.send(event).await;
1575                    }
1576                });
1577                Ok(rx)
1578            }
1579        }
1580        let runtime = RuntimeBuilder::new()
1581            .provider(Arc::new(CountingGatedModel {
1582                entered: entered.clone(),
1583                release: release.clone(),
1584                calls: calls.clone(),
1585            }))
1586            .build()
1587            .expect("runtime builds");
1588        let entered_wait = entered.notified();
1589        runtime.submit("active").await.expect("first submit");
1590        entered_wait.await;
1591        runtime.submit("pending").await.expect("pre-fence submit");
1592        let controller = runtime.shutdown_controller();
1593        let shutdown = tokio::spawn(async move {
1594            controller
1595                .shutdown(
1596                    ShutdownOptions::finish_current(
1597                        Duration::from_secs(1),
1598                        Duration::from_millis(500),
1599                    )
1600                    .expect("valid options"),
1601                )
1602                .await
1603        });
1604        tokio::task::yield_now().await;
1605        release.notify_one();
1606        let report = shutdown.await.expect("caller joins").expect("report");
1607
1608        assert_eq!(calls.load(Ordering::SeqCst), 1);
1609        assert_eq!(report.active_turn(), ShutdownActiveTurnOutcome::Finished);
1610        runtime
1611            .shutdown()
1612            .await
1613            .expect("legacy wrapper joins report");
1614    }
1615
1616    #[tokio::test]
1617    async fn cancelling_one_waiter_does_not_cancel_the_runtime_driver() {
1618        let (runtime, entered, _release) = gated_runtime().await;
1619        let entered_wait = entered.notified();
1620        runtime.submit("block").await.expect("submit succeeds");
1621        entered_wait.await;
1622        let first = runtime.shutdown_controller();
1623        let later = first.clone();
1624        let waiter = tokio::spawn(async move {
1625            first
1626                .shutdown(
1627                    ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1628                )
1629                .await
1630        });
1631        loop {
1632            if matches!(
1633                runtime.submit("fence probe").await,
1634                Err(RuntimeError::RuntimeClosing)
1635            ) {
1636                break;
1637            }
1638            tokio::task::yield_now().await;
1639        }
1640        waiter.abort();
1641
1642        let report = later
1643            .shutdown(
1644                ShutdownOptions::finish_current(Duration::from_secs(2), Duration::from_millis(100))
1645                    .expect("valid options"),
1646            )
1647            .await
1648            .expect("later caller receives cached report");
1649        assert_eq!(report.active_turn_policy(), ActiveTurnPolicy::Interrupt);
1650        assert!(report.is_complete());
1651        runtime
1652            .shutdown()
1653            .await
1654            .expect("legacy wrapper joins report");
1655    }
1656
1657    #[tokio::test]
1658    async fn primary_drop_initiates_default_plan_and_controller_drop_is_inert() {
1659        let (runtime, entered, _release) = gated_runtime().await;
1660        let entered_wait = entered.notified();
1661        runtime.submit("block").await.expect("submit succeeds");
1662        entered_wait.await;
1663        let controller = runtime.shutdown_controller();
1664        drop(runtime.shutdown_controller());
1665        drop(runtime);
1666
1667        let report = controller
1668            .shutdown(
1669                ShutdownOptions::finish_current(Duration::from_secs(2), Duration::from_millis(100))
1670                    .expect("valid options"),
1671            )
1672            .await
1673            .expect("controller joins Drop-initiated report");
1674        assert_eq!(report.active_turn_policy(), ActiveTurnPolicy::Interrupt);
1675        assert!(report.is_complete());
1676    }
1677
1678    #[tokio::test]
1679    async fn primary_drop_cannot_replace_an_explicit_winning_plan() {
1680        let (runtime, entered, release) = gated_runtime().await;
1681        let entered_wait = entered.notified();
1682        runtime.submit("block").await.expect("submit succeeds");
1683        entered_wait.await;
1684        let controller = runtime.shutdown_controller();
1685        let observer = controller.clone();
1686        let shutdown = tokio::spawn(async move {
1687            controller
1688                .shutdown(
1689                    ShutdownOptions::finish_current(
1690                        Duration::from_secs(1),
1691                        Duration::from_millis(500),
1692                    )
1693                    .expect("valid options"),
1694                )
1695                .await
1696        });
1697        loop {
1698            if matches!(
1699                runtime.submit("fence probe").await,
1700                Err(RuntimeError::RuntimeClosing)
1701            ) {
1702                break;
1703            }
1704            tokio::task::yield_now().await;
1705        }
1706        drop(runtime);
1707        release.notify_one();
1708
1709        let report = shutdown.await.expect("caller joins").expect("report");
1710        let cached = observer
1711            .shutdown(ShutdownOptions::interrupt(Duration::from_secs(2)).expect("valid options"))
1712            .await
1713            .expect("observer joins cached report");
1714        assert_eq!(report, cached);
1715        assert!(matches!(
1716            report.active_turn_policy(),
1717            ActiveTurnPolicy::FinishCurrent { .. }
1718        ));
1719    }
1720
1721    #[tokio::test]
1722    async fn legacy_shutdown_preserves_actor_join_errors() {
1723        let admission = talos_agent::session::RuntimeAdmissionControl::new();
1724        let (command_tx, command_rx) = mpsc::channel(1);
1725        drop(command_rx);
1726        let (_event_tx, event_rx) = mpsc::unbounded_channel();
1727        let actor_exit_guard = RuntimeActorExitGuard {
1728            admission: admission.clone(),
1729        };
1730        let actor_task = tokio::spawn(async move {
1731            let _actor_exit_guard = actor_exit_guard;
1732            panic!("intentional actor join failure");
1733        });
1734        let runtime_handle = tokio::runtime::Handle::current();
1735        let coordinator = ShutdownCoordinator::new(
1736            admission,
1737            command_tx.clone(),
1738            actor_task,
1739            runtime_handle,
1740            RuntimeFinalizerRegistry::freeze(Vec::new()).expect("empty registry is valid"),
1741        );
1742        let runtime = RuntimeHandle {
1743            command_tx,
1744            event_rx,
1745            coordinator,
1746            primary_drop_armed: true,
1747        };
1748
1749        assert!(matches!(
1750            runtime.shutdown().await,
1751            Err(RuntimeError::ActorJoin(_))
1752        ));
1753    }
1754
1755    #[tokio::test]
1756    async fn exhausted_total_deadline_returns_a_redacted_incomplete_report() {
1757        let runtime = RuntimeBuilder::new()
1758            .provider(Arc::new(MockProvider::new().with_response("unused")))
1759            .build()
1760            .expect("runtime builds");
1761        let report = runtime
1762            .shutdown_with(
1763                ShutdownOptions::interrupt(Duration::from_nanos(1)).expect("valid options"),
1764            )
1765            .await
1766            .expect("structured timeout remains observable");
1767
1768        assert!(report.deadline_exhausted());
1769        assert!(!report.is_complete());
1770        assert!(matches!(
1771            runtime.shutdown().await,
1772            Err(RuntimeError::ShutdownIncomplete { .. })
1773        ));
1774    }
1775
1776    #[tokio::test]
1777    async fn durable_reconciliation_failure_is_typed_and_incomplete() {
1778        let blocked_root = tempfile::NamedTempFile::new().expect("temporary file");
1779        let runtime = RuntimeBuilder::new()
1780            .provider(Arc::new(MockProvider::new().with_response("unused")))
1781            .workspace_root(blocked_root.path())
1782            .build()
1783            .expect("runtime construction is lazy over pending custody");
1784        let report = runtime
1785            .shutdown_with(
1786                ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1787            )
1788            .await
1789            .expect("structured report");
1790
1791        assert!(matches!(
1792            report.durable_reconciliation(),
1793            ShutdownDurableOutcome::Failed { .. }
1794        ));
1795        assert!(!report.is_complete());
1796        assert!(matches!(
1797            runtime.shutdown().await,
1798            Err(RuntimeError::ShutdownIncomplete { .. })
1799        ));
1800    }
1801
1802    #[tokio::test]
1803    async fn shutdown_report_never_contains_submitted_content() {
1804        let (runtime, entered, _release) = gated_runtime().await;
1805        let entered_wait = entered.notified();
1806        runtime
1807            .submit("secret-prompt-and-credential")
1808            .await
1809            .expect("submit succeeds");
1810        entered_wait.await;
1811        let report = runtime
1812            .shutdown_with(
1813                ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1814            )
1815            .await
1816            .expect("structured report");
1817
1818        let projection = format!("{report:?}");
1819        assert!(!projection.contains("secret-prompt-and-credential"));
1820        assert!(!projection.contains("GatedModel"));
1821        runtime
1822            .shutdown()
1823            .await
1824            .expect("legacy wrapper joins report");
1825    }
1826
1827    #[cfg(feature = "shared-composition")]
1828    struct PresetOverrideTool {
1829        executions: Arc<AtomicUsize>,
1830    }
1831
1832    struct SnapshotEditingModel {
1833        step: AtomicUsize,
1834        observed_snapshot: Arc<StdMutex<Option<String>>>,
1835    }
1836
1837    impl SnapshotEditingModel {
1838        fn new(observed_snapshot: Arc<StdMutex<Option<String>>>) -> Self {
1839            Self {
1840                step: AtomicUsize::new(0),
1841                observed_snapshot,
1842            }
1843        }
1844    }
1845
1846    #[async_trait]
1847    impl LanguageModel for SnapshotEditingModel {
1848        async fn stream(&self, messages: &[Message]) -> ProviderResult<mpsc::Receiver<AgentEvent>> {
1849            let step = self.step.fetch_add(1, Ordering::SeqCst);
1850            let events = if step == 0 {
1851                vec![
1852                    AgentEvent::TurnStart,
1853                    AgentEvent::ToolCall {
1854                        call: ToolCall {
1855                            id: "snapshot-read".into(),
1856                            name: "read".into(),
1857                            input: serde_json::json!({"path": "source.txt"}),
1858                        },
1859                        provenance: ToolProvenance::default(),
1860                        summary_fields: Vec::new(),
1861                    },
1862                    AgentEvent::TurnEnd {
1863                        stop_reason: StopReason::ToolUse,
1864                        usage: Usage::default(),
1865                    },
1866                ]
1867            } else if step == 1 {
1868                let content = messages
1869                    .iter()
1870                    .rev()
1871                    .find_map(|message| match message {
1872                        Message::Tool { result } => Some(result.content.as_str()),
1873                        _ => None,
1874                    })
1875                    .expect("model receives read result");
1876                let mut lines = content.lines();
1877                let snapshot_id = lines
1878                    .next()
1879                    .and_then(|line| line.strip_prefix("[snapshot:"))
1880                    .and_then(|line| line.strip_suffix(']'))
1881                    .expect("model receives snapshot handle")
1882                    .to_string();
1883                let target = lines
1884                    .next()
1885                    .and_then(|line| line.split_once('|'))
1886                    .map(|(reference, _)| reference.to_string())
1887                    .expect("model receives line reference");
1888                *self
1889                    .observed_snapshot
1890                    .lock()
1891                    .expect("snapshot capture lock") = Some(snapshot_id.clone());
1892                vec![
1893                    AgentEvent::TurnStart,
1894                    AgentEvent::ToolCall {
1895                        call: ToolCall {
1896                            id: "snapshot-edit".into(),
1897                            name: "edit".into(),
1898                            input: serde_json::json!({
1899                                "path": "source.txt",
1900                                "snapshot_id": snapshot_id,
1901                                "operations": [{
1902                                    "op": "replace",
1903                                    "target": target,
1904                                    "content": "updated"
1905                                }]
1906                            }),
1907                        },
1908                        provenance: ToolProvenance::default(),
1909                        summary_fields: Vec::new(),
1910                    },
1911                    AgentEvent::TurnEnd {
1912                        stop_reason: StopReason::ToolUse,
1913                        usage: Usage::default(),
1914                    },
1915                ]
1916            } else {
1917                vec![
1918                    AgentEvent::TurnStart,
1919                    AgentEvent::TextDelta {
1920                        delta: "done".into(),
1921                    },
1922                    AgentEvent::TurnEnd {
1923                        stop_reason: StopReason::EndTurn,
1924                        usage: Usage::default(),
1925                    },
1926                ]
1927            };
1928            let (tx, rx) = mpsc::channel(8);
1929            for event in events {
1930                tx.send(event).await.expect("runtime receiver remains open");
1931            }
1932            Ok(rx)
1933        }
1934    }
1935
1936    #[derive(Debug, Clone, PartialEq, Eq)]
1937    struct ApprovalRecord {
1938        tool_name: String,
1939        arguments: Value,
1940        summary_fields: Vec<String>,
1941    }
1942
1943    struct RecordingApprovalHandler {
1944        choice: ApprovalChoice,
1945        records: Arc<StdMutex<Vec<ApprovalRecord>>>,
1946    }
1947
1948    impl RecordingApprovalHandler {
1949        fn new(choice: ApprovalChoice, records: Arc<StdMutex<Vec<ApprovalRecord>>>) -> Self {
1950            Self { choice, records }
1951        }
1952    }
1953
1954    #[async_trait]
1955    impl ApprovalHandler for RecordingApprovalHandler {
1956        async fn request_approval(
1957            &self,
1958            tool_name: &str,
1959            arguments: &Value,
1960            summary_fields: &[String],
1961        ) -> ApprovalChoice {
1962            self.records
1963                .lock()
1964                .expect("records lock is available")
1965                .push(ApprovalRecord {
1966                    tool_name: tool_name.to_string(),
1967                    arguments: arguments.clone(),
1968                    summary_fields: summary_fields.to_vec(),
1969                });
1970            self.choice.clone()
1971        }
1972    }
1973
1974    #[cfg(feature = "shared-composition")]
1975    #[test]
1976    fn shared_tools_are_explicit_and_keep_a_unique_inventory() {
1977        let builder = RuntimeBuilder::new()
1978            .workspace_root("workspace")
1979            .shared_tools();
1980        let mut names = builder
1981            .tools
1982            .iter()
1983            .map(|tool| tool.name().to_string())
1984            .collect::<Vec<_>>();
1985        names.sort();
1986        names.dedup();
1987
1988        assert_eq!(names.len(), builder.tools.len());
1989        assert!(names.iter().any(|name| name == "read"));
1990        assert!(
1991            names
1992                .iter()
1993                .any(|name| name == "bash" || name == "powershell")
1994        );
1995        assert!(names.iter().any(|name| name == "document_extract"));
1996        assert!(names.iter().any(|name| name == "read_image"));
1997    }
1998
1999    #[cfg(feature = "shared-composition")]
2000    #[tokio::test]
2001    async fn coding_preset_is_explicit_and_builds_shared_inventory() {
2002        let builder = RuntimeBuilder::new()
2003            .provider(Arc::new(MockProvider::new().with_response("done")))
2004            .workspace_root("workspace")
2005            .coding_preset();
2006        assert_eq!(builder.preset, Some(RuntimePreset::Coding));
2007        assert!(builder.tools.is_empty());
2008        let mut runtime = builder.build().expect("coding preset builds");
2009        runtime.submit("hello").await.expect("submit succeeds");
2010        let status = collect_until_turn_completed(&mut runtime)
2011            .await
2012            .expect("turn completes");
2013        assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2014        runtime.shutdown().await.expect("shutdown succeeds");
2015    }
2016
2017    #[cfg(feature = "shared-composition")]
2018    #[tokio::test]
2019    async fn coding_preset_preserves_caller_tool_overrides() {
2020        let executions = Arc::new(AtomicUsize::new(0));
2021        let provider = MockProvider::new()
2022            .with_tool_call("bash", serde_json::json!({"command": "echo custom"}))
2023            .with_response("done");
2024        let mut runtime = RuntimeBuilder::new()
2025            .provider(Arc::new(provider))
2026            .tool(Arc::new(PresetOverrideTool {
2027                executions: executions.clone(),
2028            }))
2029            .permission_rule(PermissionRule {
2030                tool_name: "bash".into(),
2031                path_pattern: None,
2032                decision: PermissionDecision::Allow,
2033                nature: None,
2034                resource: None,
2035                resource_kind: None,
2036            })
2037            .coding_preset()
2038            .build()
2039            .expect("coding preset builds with caller override");
2040
2041        runtime
2042            .submit("run the custom tool")
2043            .await
2044            .expect("submit succeeds");
2045        let status = collect_until_turn_completed(&mut runtime)
2046            .await
2047            .expect("turn completes");
2048        assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2049        assert_eq!(executions.load(Ordering::SeqCst), 1);
2050        runtime.shutdown().await.expect("shutdown succeeds");
2051    }
2052
2053    #[cfg(not(feature = "shared-composition"))]
2054    #[test]
2055    fn coding_preset_requires_opt_in_feature() {
2056        let result = RuntimeBuilder::new()
2057            .provider(Arc::new(MockProvider::new()))
2058            .coding_preset()
2059            .build();
2060        assert!(matches!(
2061            result,
2062            Err(RuntimeError::CodingPresetRequiresFeature)
2063        ));
2064    }
2065
2066    #[async_trait]
2067    impl AgentTool for RecordingWriteTool {
2068        fn name(&self) -> &str {
2069            "record_write"
2070        }
2071
2072        fn description(&self) -> &str {
2073            "Records a write-like operation"
2074        }
2075
2076        fn parameters(&self) -> Value {
2077            serde_json::json!({
2078                "type": "object",
2079                "properties": {
2080                    "message": { "type": "string" }
2081                },
2082                "required": ["message"]
2083            })
2084        }
2085
2086        async fn execute(&self, input: Value) -> ToolResult {
2087            self.executions.fetch_add(1, Ordering::SeqCst);
2088            let message = input
2089                .get("message")
2090                .and_then(Value::as_str)
2091                .unwrap_or_default();
2092            ToolResult::success(format!("recorded: {message}"))
2093        }
2094
2095        fn nature(&self) -> ToolNature {
2096            ToolNature::Write
2097        }
2098
2099        fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
2100            vec![ToolPermissionFacet::with_resource(
2101                ToolNature::Write,
2102                ".talos-runtime-record",
2103                ToolResourceKind::Path,
2104            )]
2105        }
2106
2107        fn summary_fields(&self) -> &'static [&'static str] {
2108            &["message"]
2109        }
2110    }
2111
2112    #[cfg(feature = "shared-composition")]
2113    #[async_trait]
2114    impl AgentTool for PresetOverrideTool {
2115        fn name(&self) -> &str {
2116            "bash"
2117        }
2118
2119        fn description(&self) -> &str {
2120            "Test-only caller-provided bash override"
2121        }
2122
2123        fn parameters(&self) -> Value {
2124            serde_json::json!({
2125                "type": "object",
2126                "properties": {"command": {"type": "string"}},
2127                "required": ["command"]
2128            })
2129        }
2130
2131        async fn execute(&self, _input: Value) -> ToolResult {
2132            self.executions.fetch_add(1, Ordering::SeqCst);
2133            ToolResult::success("custom bash")
2134        }
2135
2136        fn nature(&self) -> ToolNature {
2137            ToolNature::Execute
2138        }
2139    }
2140
2141    #[async_trait]
2142    impl AgentTool for RecordingHybridTool {
2143        fn name(&self) -> &str {
2144            "record_hybrid"
2145        }
2146
2147        fn description(&self) -> &str {
2148            "Records a network plus write operation"
2149        }
2150
2151        fn parameters(&self) -> Value {
2152            serde_json::json!({
2153                "type": "object",
2154                "properties": {
2155                    "url": { "type": "string" },
2156                    "destination": { "type": "string" }
2157                },
2158                "required": ["url", "destination"]
2159            })
2160        }
2161
2162        async fn execute(&self, _input: Value) -> ToolResult {
2163            self.executions.fetch_add(1, Ordering::SeqCst);
2164            ToolResult::success("hybrid executed")
2165        }
2166
2167        fn nature(&self) -> ToolNature {
2168            ToolNature::Write
2169        }
2170
2171        fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
2172            vec![
2173                ToolPermissionFacet::with_resource(
2174                    ToolNature::Network,
2175                    "example.com",
2176                    ToolResourceKind::Domain,
2177                ),
2178                ToolPermissionFacet::with_resource(
2179                    ToolNature::Write,
2180                    "blocked/output.txt",
2181                    ToolResourceKind::Path,
2182                ),
2183            ]
2184        }
2185    }
2186
2187    #[async_trait]
2188    impl AgentTool for PrivateInputWriteTool {
2189        fn name(&self) -> &str {
2190            "private_write"
2191        }
2192
2193        fn description(&self) -> &str {
2194            "Projection test write"
2195        }
2196
2197        fn parameters(&self) -> Value {
2198            serde_json::json!({"type": "object"})
2199        }
2200
2201        async fn execute(&self, _input: Value) -> ToolResult {
2202            ToolResult::success("written")
2203        }
2204
2205        fn nature(&self) -> ToolNature {
2206            ToolNature::Write
2207        }
2208
2209        fn permission_profile(&self, input: &Value) -> Vec<ToolPermissionFacet> {
2210            input
2211                .get("path")
2212                .and_then(Value::as_str)
2213                .map(|path| {
2214                    vec![ToolPermissionFacet::with_resource(
2215                        ToolNature::Write,
2216                        path,
2217                        ToolResourceKind::Path,
2218                    )]
2219                })
2220                .unwrap_or_else(|| vec![ToolPermissionFacet::new(ToolNature::Write)])
2221        }
2222
2223        fn project_input(&self, input: &Value) -> Value {
2224            let mut input = input.clone();
2225            if let Some(object) = input.as_object_mut() {
2226                object.remove("snapshot_id");
2227            }
2228            input
2229        }
2230    }
2231
2232    #[async_trait]
2233    impl AgentTool for PrivateResultReadTool {
2234        fn name(&self) -> &str {
2235            "private_read"
2236        }
2237
2238        fn description(&self) -> &str {
2239            "Projection test read"
2240        }
2241
2242        fn parameters(&self) -> Value {
2243            serde_json::json!({"type": "object"})
2244        }
2245
2246        async fn execute(&self, _input: Value) -> ToolResult {
2247            ToolResult::success("[snapshot:s1]\n1:aa|private line")
2248        }
2249
2250        fn is_read_only(&self) -> bool {
2251            true
2252        }
2253
2254        fn project_result(&self, result: &ToolResult) -> talos_core::tool::ToolResultProjection {
2255            talos_core::tool::ToolResultProjection {
2256                model_content: result.content.clone(),
2257                display_content: "read 1 line".into(),
2258                persistence_content: "read 1 line".into(),
2259            }
2260        }
2261    }
2262
2263    #[tokio::test]
2264    async fn runtime_streams_mock_response() {
2265        let provider = Arc::new(MockProvider::new().with_response("hello from runtime"));
2266        let mut runtime = RuntimeBuilder::new()
2267            .provider(provider)
2268            .workspace_root(".")
2269            .build()
2270            .expect("runtime builds");
2271
2272        runtime.submit("hello").await.expect("submit succeeds");
2273        let status = collect_until_turn_completed(&mut runtime)
2274            .await
2275            .expect("turn completes");
2276
2277        match status {
2278            TurnCompletionStatus::Success { final_text, .. } => {
2279                assert_eq!(final_text, "hello from runtime");
2280            }
2281            other => panic!("unexpected status: {other:?}"),
2282        }
2283
2284        runtime.shutdown().await.expect("shutdown succeeds");
2285    }
2286
2287    #[tokio::test]
2288    async fn runtime_denies_ask_tools_by_default() {
2289        let provider = Arc::new(
2290            MockProvider::new()
2291                .with_tool_call("record_write", serde_json::json!({"message": "secret"}))
2292                .with_response("done"),
2293        );
2294        let executions = Arc::new(AtomicUsize::new(0));
2295        let tool = Arc::new(RecordingWriteTool {
2296            executions: executions.clone(),
2297        });
2298        let mut runtime = RuntimeBuilder::new()
2299            .provider(provider)
2300            .workspace_root(".")
2301            .tool(tool)
2302            .build()
2303            .expect("runtime builds");
2304
2305        runtime
2306            .submit("write something")
2307            .await
2308            .expect("submit succeeds");
2309        let status = collect_until_turn_completed(&mut runtime)
2310            .await
2311            .expect("turn completes");
2312
2313        assert!(matches!(
2314            status,
2315            TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2316        ));
2317        assert_eq!(executions.load(Ordering::SeqCst), 0);
2318
2319        runtime.shutdown().await.expect("shutdown succeeds");
2320    }
2321
2322    #[tokio::test]
2323    async fn runtime_allows_tool_when_rule_allows_write() {
2324        let provider = Arc::new(
2325            MockProvider::new()
2326                .with_tool_call("record_write", serde_json::json!({"message": "allowed"}))
2327                .with_response("done"),
2328        );
2329        let executions = Arc::new(AtomicUsize::new(0));
2330        let tool = Arc::new(RecordingWriteTool {
2331            executions: executions.clone(),
2332        });
2333        let mut runtime = RuntimeBuilder::new()
2334            .provider(provider)
2335            .workspace_root(".")
2336            .permission_rule(PermissionRule::new_nature(
2337                ToolNature::Write,
2338                None,
2339                None,
2340                PermissionDecision::Allow,
2341            ))
2342            .tool(tool)
2343            .build()
2344            .expect("runtime builds");
2345
2346        runtime
2347            .submit("write something")
2348            .await
2349            .expect("submit succeeds");
2350        let status = collect_until_turn_completed(&mut runtime)
2351            .await
2352            .expect("turn completes");
2353
2354        assert!(matches!(
2355            status,
2356            TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2357        ));
2358        assert_eq!(executions.load(Ordering::SeqCst), 1);
2359
2360        runtime.shutdown().await.expect("shutdown succeeds");
2361    }
2362
2363    #[tokio::test]
2364    async fn runtime_approval_handler_can_approve_ask_tool() {
2365        let provider = Arc::new(
2366            MockProvider::new()
2367                .with_tool_call("record_write", serde_json::json!({"message": "approved"}))
2368                .with_response("done"),
2369        );
2370        let executions = Arc::new(AtomicUsize::new(0));
2371        let approval_records = Arc::new(StdMutex::new(Vec::new()));
2372        let tool = Arc::new(RecordingWriteTool {
2373            executions: executions.clone(),
2374        });
2375        let approval_handler = Arc::new(RecordingApprovalHandler::new(
2376            ApprovalChoice::ApproveOnce,
2377            approval_records.clone(),
2378        ));
2379        let mut runtime = RuntimeBuilder::new()
2380            .provider(provider)
2381            .workspace_root(".")
2382            .approval_handler(approval_handler)
2383            .tool(tool)
2384            .build()
2385            .expect("runtime builds");
2386
2387        runtime
2388            .submit("write something")
2389            .await
2390            .expect("submit succeeds");
2391        let status = collect_until_turn_completed(&mut runtime)
2392            .await
2393            .expect("turn completes");
2394
2395        assert!(matches!(
2396            status,
2397            TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2398        ));
2399        assert_eq!(executions.load(Ordering::SeqCst), 1);
2400        {
2401            let records = approval_records.lock().expect("records lock is available");
2402            assert_eq!(records.len(), 1);
2403            assert_eq!(records[0].tool_name, "record_write");
2404            assert_eq!(
2405                records[0].arguments,
2406                serde_json::json!({"message": "approved"})
2407            );
2408            assert_eq!(records[0].summary_fields, vec!["message"]);
2409        }
2410
2411        runtime.shutdown().await.expect("shutdown succeeds");
2412    }
2413
2414    #[tokio::test]
2415    async fn external_read_requires_approval_and_receives_exact_path_authorization() {
2416        let workspace = tempfile::tempdir().expect("workspace");
2417        let external = tempfile::NamedTempFile::new().expect("external file");
2418        std::fs::write(external.path(), "external content").expect("write fixture");
2419        let records = Arc::new(StdMutex::new(Vec::new()));
2420        let handler = Arc::new(RecordingApprovalHandler::new(
2421            ApprovalChoice::ApproveOnce,
2422            records.clone(),
2423        ));
2424        let tool = RuntimePermissionAwareTool {
2425            inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2426            permission_state: Arc::new(PermissionSessionState::new(
2427                PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2428            )),
2429            approval_handler: Some(handler),
2430        };
2431
2432        let result = tool
2433            .execute(serde_json::json!({"path": external.path().to_string_lossy()}))
2434            .await;
2435
2436        assert!(!result.is_error, "{}", result.content);
2437        assert!(result.content.contains("external content"));
2438        assert_eq!(records.lock().expect("records lock").len(), 1);
2439    }
2440
2441    #[tokio::test]
2442    async fn external_read_without_handler_fails_closed() {
2443        let workspace = tempfile::tempdir().expect("workspace");
2444        let external = tempfile::NamedTempFile::new().expect("external file");
2445        std::fs::write(external.path(), "must not be read").expect("write fixture");
2446        let tool = RuntimePermissionAwareTool {
2447            inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2448            permission_state: Arc::new(PermissionSessionState::new(
2449                PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2450            )),
2451            approval_handler: None,
2452        };
2453
2454        let result = tool
2455            .execute(serde_json::json!({"path": external.path().to_string_lossy()}))
2456            .await;
2457
2458        assert!(result.is_error);
2459        assert!(result.content.contains("approval required"));
2460        assert!(!result.content.contains("must not be read"));
2461    }
2462
2463    #[tokio::test]
2464    async fn external_read_explicit_denial_fails_closed() {
2465        let workspace = tempfile::tempdir().expect("workspace");
2466        let external = tempfile::NamedTempFile::new().expect("external file");
2467        std::fs::write(external.path(), "private").expect("write fixture");
2468        let handler = Arc::new(RecordingApprovalHandler::new(
2469            ApprovalChoice::Deny,
2470            Arc::new(StdMutex::new(Vec::new())),
2471        ));
2472        let tool = RuntimePermissionAwareTool {
2473            inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2474            permission_state: Arc::new(PermissionSessionState::new(
2475                PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2476            )),
2477            approval_handler: Some(handler),
2478        };
2479
2480        let result = tool
2481            .execute(serde_json::json!({"path": external.path().to_string_lossy()}))
2482            .await;
2483
2484        assert!(result.is_error);
2485        assert!(result.content.contains("User denied"));
2486        assert!(!result.content.contains("private"));
2487    }
2488
2489    #[tokio::test]
2490    async fn external_read_always_approve_reuses_exact_session_grant_without_second_prompt() {
2491        let workspace = tempfile::tempdir().expect("workspace");
2492        let external = tempfile::NamedTempFile::new().expect("external file");
2493        std::fs::write(external.path(), "external content").expect("write fixture");
2494        let records = Arc::new(StdMutex::new(Vec::new()));
2495        let handler = Arc::new(RecordingApprovalHandler::new(
2496            ApprovalChoice::AlwaysApprove,
2497            records.clone(),
2498        ));
2499        let tool = RuntimePermissionAwareTool {
2500            inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2501            permission_state: Arc::new(PermissionSessionState::new(
2502                PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2503            )),
2504            approval_handler: Some(handler),
2505        };
2506        let input = serde_json::json!({"path": external.path().to_string_lossy()});
2507
2508        let first = tool.execute(input.clone()).await;
2509        let second = tool.execute(input).await;
2510
2511        assert!(!first.is_error, "{}", first.content);
2512        assert!(!second.is_error, "{}", second.content);
2513        assert_eq!(
2514            records.lock().expect("records lock").len(),
2515            1,
2516            "exact Session grant must suppress the second prompt"
2517        );
2518    }
2519
2520    #[tokio::test]
2521    async fn runtime_approval_receives_projected_input_without_private_snapshot_id() {
2522        let provider = Arc::new(
2523            MockProvider::new()
2524                .with_tool_call(
2525                    "private_write",
2526                    serde_json::json!({"path": "src/lib.rs", "snapshot_id": "s1"}),
2527                )
2528                .with_response("done"),
2529        );
2530        let approval_records = Arc::new(StdMutex::new(Vec::new()));
2531        let approval_handler = Arc::new(RecordingApprovalHandler::new(
2532            ApprovalChoice::ApproveOnce,
2533            approval_records.clone(),
2534        ));
2535        let mut runtime = RuntimeBuilder::new()
2536            .provider(provider)
2537            .workspace_root(".")
2538            .approval_handler(approval_handler)
2539            .tool(Arc::new(PrivateInputWriteTool))
2540            .build()
2541            .expect("runtime builds");
2542
2543        runtime.submit("write").await.expect("submit succeeds");
2544        let status = collect_until_turn_completed(&mut runtime)
2545            .await
2546            .expect("turn completes");
2547        assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2548        {
2549            let records = approval_records.lock().expect("records lock");
2550            assert_eq!(records.len(), 1);
2551            assert_eq!(
2552                records[0].arguments,
2553                serde_json::json!({"path": "src/lib.rs"})
2554            );
2555        }
2556        runtime.shutdown().await.expect("shutdown succeeds");
2557    }
2558
2559    #[tokio::test]
2560    async fn runtime_always_approve_installs_in_memory_session_grant() {
2561        let provider = Arc::new(
2562            MockProvider::new()
2563                .with_tool_call("record_write", serde_json::json!({"message": "first"}))
2564                .with_response("first done")
2565                .with_tool_call("record_write", serde_json::json!({"message": "second"}))
2566                .with_response("second done"),
2567        );
2568        let executions = Arc::new(AtomicUsize::new(0));
2569        let approval_records = Arc::new(StdMutex::new(Vec::new()));
2570        let tool = Arc::new(RecordingWriteTool {
2571            executions: executions.clone(),
2572        });
2573        let approval_handler = Arc::new(RecordingApprovalHandler::new(
2574            ApprovalChoice::AlwaysApprove,
2575            approval_records.clone(),
2576        ));
2577        let mut runtime = RuntimeBuilder::new()
2578            .provider(provider)
2579            .workspace_root(".")
2580            .approval_handler(approval_handler)
2581            .tool(tool)
2582            .build()
2583            .expect("runtime builds");
2584
2585        runtime
2586            .submit("write first")
2587            .await
2588            .expect("first submit succeeds");
2589        let first_status = collect_until_turn_completed(&mut runtime)
2590            .await
2591            .expect("first turn completes");
2592        runtime
2593            .submit("write second")
2594            .await
2595            .expect("second submit succeeds");
2596        let second_status = collect_until_turn_completed(&mut runtime)
2597            .await
2598            .expect("second turn completes");
2599
2600        assert!(matches!(
2601            first_status,
2602            TurnCompletionStatus::Success { final_text, .. } if final_text == "first done"
2603        ));
2604        assert!(matches!(
2605            second_status,
2606            TurnCompletionStatus::Success { final_text, .. } if final_text == "second done"
2607        ));
2608        assert_eq!(executions.load(Ordering::SeqCst), 2);
2609        assert_eq!(
2610            approval_records
2611                .lock()
2612                .expect("records lock is available")
2613                .len(),
2614            1
2615        );
2616
2617        runtime.shutdown().await.expect("shutdown succeeds");
2618    }
2619
2620    #[tokio::test]
2621    async fn runtime_denies_hybrid_tool_when_write_facet_is_denied() {
2622        let provider = Arc::new(
2623            MockProvider::new()
2624                .with_tool_call(
2625                    "record_hybrid",
2626                    serde_json::json!({
2627                        "url": "https://example.com/file",
2628                        "destination": "blocked/output.txt"
2629                    }),
2630                )
2631                .with_response("done"),
2632        );
2633        let executions = Arc::new(AtomicUsize::new(0));
2634        let tool = Arc::new(RecordingHybridTool {
2635            executions: executions.clone(),
2636        });
2637        let mut runtime = RuntimeBuilder::new()
2638            .provider(provider)
2639            .workspace_root(".")
2640            .permission_rule(PermissionRule::new_nature(
2641                ToolNature::Network,
2642                Some("example.com".to_string()),
2643                Some(talos_permission::ResourceKind::Domain),
2644                PermissionDecision::Allow,
2645            ))
2646            .permission_rule(PermissionRule::new_nature(
2647                ToolNature::Write,
2648                Some("blocked/**".to_string()),
2649                Some(talos_permission::ResourceKind::Path),
2650                PermissionDecision::Deny("write blocked".to_string()),
2651            ))
2652            .tool(tool)
2653            .build()
2654            .expect("runtime builds");
2655
2656        runtime
2657            .submit("fetch and save")
2658            .await
2659            .expect("submit succeeds");
2660        let status = collect_until_turn_completed(&mut runtime)
2661            .await
2662            .expect("turn completes");
2663
2664        assert!(matches!(
2665            status,
2666            TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2667        ));
2668        assert_eq!(executions.load(Ordering::SeqCst), 0);
2669
2670        runtime.shutdown().await.expect("shutdown succeeds");
2671    }
2672
2673    #[tokio::test]
2674    async fn runtime_accepts_initial_history() {
2675        let provider = Arc::new(MockProvider::new().with_response("continued"));
2676        let mut runtime = RuntimeBuilder::new()
2677            .provider(provider)
2678            .initial_history(vec![Message::User {
2679                content: "earlier".into(),
2680            }])
2681            .build()
2682            .expect("runtime builds");
2683
2684        runtime.submit("continue").await.expect("submit succeeds");
2685        let status = collect_until_turn_completed(&mut runtime)
2686            .await
2687            .expect("turn completes");
2688        assert!(matches!(
2689            status,
2690            TurnCompletionStatus::Success { final_text, .. } if final_text == "continued"
2691        ));
2692
2693        runtime.shutdown().await.expect("shutdown succeeds");
2694    }
2695
2696    #[tokio::test]
2697    async fn durable_runtime_restores_history_and_reports_committed_entries() {
2698        let directory = std::env::temp_dir().join(format!(
2699            "talos-runtime-durable-{}",
2700            std::time::SystemTime::now()
2701                .duration_since(std::time::UNIX_EPOCH)
2702                .expect("clock after epoch")
2703                .as_nanos()
2704        ));
2705        let manager = SessionManager::with_dir(directory.clone());
2706        let session = manager
2707            .create_or_open_session("task:durable-runtime")
2708            .expect("durable session");
2709        let mut runtime = RuntimeBuilder::new()
2710            .provider(Arc::new(
2711                MockProvider::new().with_response("persisted answer"),
2712            ))
2713            .durable_session(session.clone())
2714            .build()
2715            .expect("runtime builds");
2716
2717        runtime
2718            .submit("persist this user turn")
2719            .await
2720            .expect("submit");
2721        let mut committed = false;
2722        while let Some(event) = runtime.next_event().await {
2723            if let SessionEvent::EntriesCommitted { entry_ids, .. } = &event {
2724                committed = !entry_ids.is_empty();
2725            }
2726            if matches!(
2727                event,
2728                SessionEvent::TurnEvent {
2729                    payload: talos_core::session::TurnEventPayload::Completed { .. },
2730                    ..
2731                }
2732            ) {
2733                break;
2734            }
2735        }
2736        assert!(committed, "durable success must report committed entry IDs");
2737        runtime.shutdown().await.expect("shutdown");
2738
2739        let restored = manager
2740            .get_session_by_external_id("task:durable-runtime")
2741            .expect("lookup")
2742            .expect("binding exists");
2743        let history = restored.read_messages().expect("history");
2744        assert!(history.iter().any(|message| matches!(message, Message::User { content } if content == "persist this user turn")));
2745        assert!(history.iter().any(|message| matches!(message, Message::Assistant { content, .. } if content == "persisted answer")));
2746        std::fs::remove_dir_all(directory).expect("cleanup");
2747    }
2748
2749    #[tokio::test]
2750    async fn durable_runtime_never_persists_model_private_tool_result() {
2751        let directory = std::env::temp_dir().join(format!(
2752            "talos-runtime-private-projection-{}",
2753            std::process::id()
2754        ));
2755        let _ = std::fs::remove_dir_all(&directory);
2756        let manager = SessionManager::with_dir(directory.clone());
2757        let session = manager
2758            .create_or_open_session("private-projection")
2759            .expect("durable session");
2760        let provider = Arc::new(
2761            MockProvider::new()
2762                .with_tool_call("private_read", serde_json::json!({"path": "src/lib.rs"}))
2763                .with_response("done"),
2764        );
2765        let mut runtime = RuntimeBuilder::new()
2766            .provider(provider)
2767            .tool(Arc::new(PrivateResultReadTool))
2768            .durable_session(session.clone())
2769            .build()
2770            .expect("runtime builds");
2771
2772        runtime.submit("read").await.expect("submit");
2773        let status = collect_until_turn_completed(&mut runtime)
2774            .await
2775            .expect("turn completes");
2776        assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2777        runtime.shutdown().await.expect("shutdown");
2778
2779        let messages = session.read_messages().expect("messages");
2780        let serialized = serde_json::to_string(&messages).expect("serialize messages");
2781        assert!(serialized.contains("read 1 line"));
2782        assert!(!serialized.contains("snapshot:s1"));
2783        assert!(!serialized.contains("1:aa|"));
2784        for entry in std::fs::read_dir(&directory).expect("session directory") {
2785            let path = entry.expect("directory entry").path();
2786            if path.extension().and_then(|value| value.to_str()) == Some("tlog") {
2787                let bytes = std::fs::read(&path).expect("tlog bytes");
2788                let text = String::from_utf8_lossy(&bytes);
2789                assert!(!text.contains("snapshot:s1"));
2790                assert!(!text.contains("1:aa|"));
2791            }
2792        }
2793        std::fs::remove_dir_all(directory).expect("cleanup");
2794    }
2795
2796    #[cfg(feature = "shared-composition")]
2797    #[tokio::test]
2798    async fn shared_composition_runtime_executes_read_tool() {
2799        let workspace = tempfile::tempdir().expect("workspace");
2800        std::fs::write(workspace.path().join("note.txt"), "shared runtime\n").expect("fixture");
2801        let provider = Arc::new(
2802            MockProvider::new()
2803                .with_tool_call("read", serde_json::json!({"path": "note.txt"}))
2804                .with_response("read complete"),
2805        );
2806        let mut runtime = RuntimeBuilder::new()
2807            .provider(provider)
2808            .workspace_root(workspace.path())
2809            .shared_tools()
2810            .build()
2811            .expect("shared runtime builds");
2812
2813        runtime.submit("read note").await.expect("submit");
2814        let status = collect_until_turn_completed(&mut runtime)
2815            .await
2816            .expect("turn completes");
2817        assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2818        runtime.shutdown().await.expect("shutdown");
2819    }
2820
2821    #[tokio::test]
2822    async fn real_snapshot_read_to_edit_is_atomic_permission_gated_and_never_durable() {
2823        let workspace = tempfile::tempdir().expect("workspace");
2824        let session_root = tempfile::tempdir().expect("session root");
2825        let source = workspace.path().join("source.txt");
2826        std::fs::write(&source, "original\n").expect("fixture write");
2827        let manager = SessionManager::with_dir(session_root.path().join("messages"));
2828        let session = manager
2829            .create_or_open_session("snapshot:e2e")
2830            .expect("durable session");
2831        let observed_snapshot = Arc::new(StdMutex::new(None));
2832        let provider = Arc::new(SnapshotEditingModel::new(observed_snapshot.clone()));
2833        let (read, write, edit, delete) = snapshot_aware_file_tools(workspace.path().to_path_buf());
2834        let mut runtime = RuntimeBuilder::new()
2835            .provider(provider)
2836            .workspace_root(workspace.path())
2837            .permission_rule(PermissionRule::new_nature(
2838                ToolNature::Write,
2839                None,
2840                None,
2841                PermissionDecision::Allow,
2842            ))
2843            .tool(Arc::new(read))
2844            .tool(Arc::new(write))
2845            .tool(Arc::new(edit))
2846            .tool(Arc::new(delete))
2847            .durable_session(session.clone())
2848            .build()
2849            .expect("runtime builds");
2850
2851        runtime
2852            .submit("update the first line")
2853            .await
2854            .expect("submit");
2855        let mut serialized_events = Vec::new();
2856        while let Some(event) = runtime.next_event().await {
2857            serialized_events.push(serde_json::to_string(&event).expect("serialize event"));
2858            if matches!(
2859                event,
2860                SessionEvent::TurnEvent {
2861                    payload: talos_core::session::TurnEventPayload::Completed { .. },
2862                    ..
2863                }
2864            ) {
2865                break;
2866            }
2867        }
2868        runtime.shutdown().await.expect("shutdown");
2869
2870        assert_eq!(
2871            std::fs::read_to_string(&source).expect("read source"),
2872            "updated\n"
2873        );
2874        let snapshot_id = observed_snapshot
2875            .lock()
2876            .expect("snapshot lock")
2877            .clone()
2878            .expect("model observed snapshot");
2879        for event in &serialized_events {
2880            assert!(!event.contains(&snapshot_id));
2881            assert!(!event.contains("snapshot_id"));
2882        }
2883        let messages = session.read_messages().expect("durable messages");
2884        let serialized = serde_json::to_string(&messages).expect("serialize messages");
2885        assert!(!serialized.contains(&snapshot_id));
2886        assert!(!serialized.contains("snapshot_id"));
2887        assert!(serialized.contains("1: original"));
2888        for entry in
2889            std::fs::read_dir(session_root.path().join("messages")).expect("session directory")
2890        {
2891            let path = entry.expect("directory entry").path();
2892            if path.extension().and_then(|value| value.to_str()) == Some("tlog") {
2893                let bytes = std::fs::read(path).expect("tlog bytes");
2894                let text = String::from_utf8_lossy(&bytes);
2895                assert!(!text.contains(&snapshot_id));
2896                assert!(!text.contains("snapshot_id"));
2897            }
2898        }
2899    }
2900
2901    #[tokio::test]
2902    async fn denied_real_snapshot_edit_leaves_the_file_unchanged() {
2903        let workspace = tempfile::tempdir().expect("workspace");
2904        let source = workspace.path().join("source.txt");
2905        std::fs::write(&source, "original\n").expect("fixture write");
2906        let observed_snapshot = Arc::new(StdMutex::new(None));
2907        let provider = Arc::new(SnapshotEditingModel::new(observed_snapshot.clone()));
2908        let (read, _, edit, _) = snapshot_aware_file_tools(workspace.path().to_path_buf());
2909        let mut runtime = RuntimeBuilder::new()
2910            .provider(provider)
2911            .workspace_root(workspace.path())
2912            .tool(Arc::new(read))
2913            .tool(Arc::new(edit))
2914            .build()
2915            .expect("runtime builds");
2916
2917        runtime
2918            .submit("update the first line")
2919            .await
2920            .expect("submit");
2921        let status = collect_until_turn_completed(&mut runtime)
2922            .await
2923            .expect("turn completes");
2924        runtime.shutdown().await.expect("shutdown");
2925
2926        assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2927        assert!(observed_snapshot.lock().expect("snapshot lock").is_some());
2928        assert_eq!(
2929            std::fs::read_to_string(source).expect("read source"),
2930            "original\n"
2931        );
2932    }
2933
2934    #[tokio::test]
2935    async fn runtime_previews_request_without_submit_magic_string() {
2936        let provider = Arc::new(MockProvider::new().with_request_debug_builder(|messages| {
2937            serde_json::to_string(messages).expect("messages serialize")
2938        }));
2939        let mut runtime = RuntimeBuilder::new()
2940            .provider(provider)
2941            .build()
2942            .expect("runtime builds");
2943
2944        runtime
2945            .preview_request("inspect request")
2946            .await
2947            .expect("preview request succeeds");
2948        let status = collect_until_turn_completed(&mut runtime)
2949            .await
2950            .expect("turn completes");
2951
2952        match status {
2953            TurnCompletionStatus::Success { final_text, .. } => {
2954                assert!(final_text.contains("Request preview (no API call made)"));
2955                assert!(final_text.contains("inspect request"));
2956            }
2957            other => panic!("unexpected status: {other:?}"),
2958        }
2959
2960        runtime.shutdown().await.expect("shutdown succeeds");
2961    }
2962
2963    #[tokio::test]
2964    async fn runtime_builder_custom_prompt_replaces_default_identity() {
2965        let provider = Arc::new(MockProvider::new().with_request_debug_builder(|messages| {
2966            serde_json::to_string(messages).expect("messages serialize")
2967        }));
2968        let mut runtime = RuntimeBuilder::new()
2969            .provider(provider)
2970            .custom_prompt("You are Obei Buddy, a zh-CN office assistant.")
2971            .append_prompt("Answer in concise business Chinese.")
2972            .build()
2973            .expect("runtime builds");
2974
2975        runtime
2976            .preview_request("inspect request")
2977            .await
2978            .expect("preview request succeeds");
2979        let status = collect_until_turn_completed(&mut runtime)
2980            .await
2981            .expect("turn completes");
2982
2983        match status {
2984            TurnCompletionStatus::Success { final_text, .. } => {
2985                assert!(final_text.contains("You are Obei Buddy"));
2986                assert!(final_text.contains("Answer in concise business Chinese."));
2987                assert!(
2988                    !final_text.contains("You are Talos, an AI coding assistant"),
2989                    "custom prompt should replace the default Talos identity"
2990                );
2991            }
2992            other => panic!("unexpected status: {other:?}"),
2993        }
2994
2995        runtime.shutdown().await.expect("shutdown succeeds");
2996    }
2997}