Skip to main content

meerkat_core/service/
mod.rs

1//! SessionService trait — canonical lifecycle abstraction.
2//!
3//! All surfaces (CLI, REST, MCP Server, JSON-RPC) route through `SessionService`.
4//! Implementations may be ephemeral (in-memory only) or persistent (backed by a store).
5
6pub mod transport;
7
8use crate::event::AgentEvent;
9use crate::event::EventEnvelope;
10use crate::lifecycle::run_primitive::RuntimeTurnMetadata;
11use crate::session::{PendingSystemContextAppend, SystemContextStageError};
12use crate::time_compat::SystemTime;
13#[cfg(target_arch = "wasm32")]
14use crate::tokio;
15use crate::types::{
16    ContentInput, HandlingMode, Message, RenderMetadata, RunResult, SessionId, ToolDef, Usage,
17};
18use crate::{
19    AgentToolDispatcher, BudgetLimits, HookRunOverrides, OutputSchema, PeerMeta, Provider, Session,
20    SessionLlmIdentity, ToolCategoryOverride,
21};
22use crate::{EventStream, StreamError};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use std::collections::BTreeSet;
27use std::sync::Arc;
28use tokio::sync::mpsc;
29
30/// Controls whether `create_session()` should execute an initial turn.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum InitialTurnPolicy {
33    /// Run the initial turn immediately as part of session creation.
34    RunImmediately,
35    /// Register the session and return without running an initial turn.
36    ///
37    /// `CreateSessionRequest::deferred_prompt_policy` determines whether the
38    /// create-time prompt is discarded or staged for the first later turn.
39    Defer,
40}
41
42/// How a deferred create request treats its create-time prompt.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum DeferredPromptPolicy {
46    /// Register the session only; the caller will supply the first runtime input separately.
47    #[default]
48    Discard,
49    /// Persist the create-time prompt and merge it into the first later turn.
50    Stage,
51}
52
53/// Errors returned by `SessionService` methods.
54#[derive(Debug, thiserror::Error)]
55pub enum SessionError {
56    /// The requested session does not exist.
57    #[error("session not found: {id}")]
58    NotFound { id: SessionId },
59
60    /// A turn is already in progress on this session.
61    #[error("session is busy: {id}")]
62    Busy { id: SessionId },
63
64    /// The operation requires persistence but the `session-store` feature is disabled.
65    #[error("session persistence is disabled")]
66    PersistenceDisabled,
67
68    /// The operation requires compaction but the `session-compaction` feature is disabled.
69    #[error("session compaction is disabled")]
70    CompactionDisabled,
71
72    /// No turn is currently running on this session.
73    #[error("no turn running on session: {id}")]
74    NotRunning { id: SessionId },
75
76    /// A session store operation failed.
77    #[error("store error: {0}")]
78    Store(#[source] Box<dyn std::error::Error + Send + Sync>),
79
80    /// An agent-level error occurred during execution.
81    #[error("agent error: {0}")]
82    Agent(#[from] crate::error::AgentError),
83
84    /// The operation failed with structured error data for protocol surfaces.
85    #[error("{message}")]
86    FailedWithData {
87        message: String,
88        data: serde_json::Value,
89    },
90
91    /// The requested operation is not supported by this session service.
92    #[error("unsupported: {0}")]
93    Unsupported(String),
94}
95
96impl SessionError {
97    /// Return a stable error code string for wire formats.
98    pub fn code(&self) -> &'static str {
99        match self {
100            Self::NotFound { .. } => "SESSION_NOT_FOUND",
101            Self::Busy { .. } => "SESSION_BUSY",
102            Self::PersistenceDisabled => "SESSION_PERSISTENCE_DISABLED",
103            Self::CompactionDisabled => "SESSION_COMPACTION_DISABLED",
104            Self::NotRunning { .. } => "SESSION_NOT_RUNNING",
105            Self::Store(_) => "SESSION_STORE_ERROR",
106            Self::Unsupported(_) => "SESSION_UNSUPPORTED",
107            Self::Agent(_) => "AGENT_ERROR",
108            Self::FailedWithData { .. } => "SESSION_ERROR",
109        }
110    }
111
112    pub fn structured_data(&self) -> Option<serde_json::Value> {
113        match self {
114            Self::FailedWithData { data, .. } => Some(data.clone()),
115            _ => None,
116        }
117    }
118}
119
120/// Errors returned by session control-plane mutation methods.
121#[derive(Debug, thiserror::Error)]
122pub enum SessionControlError {
123    /// A lifecycle/session-store error occurred while handling the control request.
124    #[error(transparent)]
125    Session(#[from] SessionError),
126
127    /// The control request was malformed.
128    #[error("invalid system-context request: {message}")]
129    InvalidRequest { message: String },
130
131    /// The idempotency key was replayed with different request content.
132    #[error(
133        "system-context idempotency conflict on session {id}: key '{key}' already maps to different content"
134    )]
135    Conflict { id: SessionId, key: String },
136}
137
138impl SessionControlError {
139    /// Return a stable error code string for wire formats.
140    pub fn code(&self) -> &'static str {
141        match self {
142            Self::Session(err) => err.code(),
143            Self::InvalidRequest { .. } => "INVALID_PARAMS",
144            Self::Conflict { .. } => "SESSION_SYSTEM_CONTEXT_CONFLICT",
145        }
146    }
147}
148
149impl SystemContextStageError {
150    /// Convert a stage-time state conflict into a surface-level control error.
151    pub fn into_control_error(self, id: &SessionId) -> SessionControlError {
152        match self {
153            Self::InvalidRequest(message) => SessionControlError::InvalidRequest { message },
154            Self::Conflict { key, .. } => SessionControlError::Conflict {
155                id: id.clone(),
156                key,
157            },
158        }
159    }
160}
161
162/// Request to create a new session and run the first turn.
163#[derive(Debug)]
164pub struct CreateSessionRequest {
165    /// Model name (e.g. "claude-opus-4-6").
166    pub model: String,
167    /// Initial user prompt (text or multimodal).
168    pub prompt: ContentInput,
169    /// Optional normalized rendering metadata for the initial prompt.
170    pub render_metadata: Option<RenderMetadata>,
171    /// Optional system prompt override.
172    pub system_prompt: Option<String>,
173    /// Max tokens per LLM turn.
174    pub max_tokens: Option<u32>,
175    /// Channel for streaming events during the turn.
176    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
177    /// Canonical SkillKeys to resolve and inject for the first turn.
178    pub skill_references: Option<Vec<crate::skills::SkillKey>>,
179    /// Initial turn behavior for this session creation call.
180    pub initial_turn: InitialTurnPolicy,
181    /// How to treat `prompt` when `initial_turn == Defer`.
182    pub deferred_prompt_policy: DeferredPromptPolicy,
183    /// Optional extended build options for factory-backed builders.
184    pub build: Option<SessionBuildOptions>,
185    /// Optional key-value labels attached at session creation.
186    pub labels: Option<BTreeMap<String, String>>,
187}
188
189impl CreateSessionRequest {
190    /// Compose the existing service-level labels and build app-context into the
191    /// shared surface metadata contract.
192    #[must_use]
193    pub fn surface_metadata(&self) -> crate::SurfaceMetadata {
194        crate::SurfaceMetadata::from_optional_parts(
195            self.labels.clone(),
196            self.build
197                .as_ref()
198                .and_then(|build| build.app_context.clone()),
199        )
200    }
201}
202
203/// Optional build-time options used by factory-backed session builders.
204#[derive(Clone)]
205pub struct SessionBuildOptions {
206    pub provider: Option<Provider>,
207    pub self_hosted_server_id: Option<String>,
208    pub output_schema: Option<OutputSchema>,
209    pub structured_output_retries: u32,
210    pub hooks_override: HookRunOverrides,
211    pub comms_name: Option<String>,
212    pub peer_meta: Option<PeerMeta>,
213    pub resume_session: Option<Session>,
214    pub budget_limits: Option<BudgetLimits>,
215    pub provider_params: Option<serde_json::Value>,
216    pub external_tools: Option<Arc<dyn AgentToolDispatcher>>,
217    /// Serializable tool definitions used to reconstruct recoverable
218    /// surface-owned dispatchers during session resume/rebuild.
219    pub recoverable_tool_defs: Option<Vec<crate::ToolDef>>,
220    /// Blob store used to externalize durable image content and hydrate refs
221    /// back to bytes at execution seams.
222    pub blob_store_override: Option<Arc<dyn crate::BlobStore>>,
223    /// Opaque transport for an optional per-request LLM override.
224    ///
225    /// Factory builders may downcast this to their concrete client trait.
226    pub llm_client_override: Option<Arc<dyn std::any::Any + Send + Sync>>,
227    // NOTE: ops_lifecycle_override was removed in Phase 3.
228    // Use runtime_build_mode instead.
229    pub override_builtins: ToolCategoryOverride,
230    pub override_shell: ToolCategoryOverride,
231    pub override_memory: ToolCategoryOverride,
232    /// Per-build override for the factory-level scheduler capability.
233    pub override_schedule: ToolCategoryOverride,
234    pub override_mob: ToolCategoryOverride,
235    /// Agent-facing scheduler tools supplied by the embedding surface.
236    ///
237    /// Scheduler remains surface-owned. This dispatcher only controls
238    /// tool visibility/composition for the built agent.
239    pub schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
240    pub preload_skills: Option<Vec<crate::skills::SkillKey>>,
241    pub realm_id: Option<String>,
242    pub instance_id: Option<String>,
243    pub backend: Option<String>,
244    pub config_generation: Option<u64>,
245    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
246    /// Flows into `AgentBuildConfig.auth_binding` via `FactoryAgentBuilder`.
247    pub auth_binding: Option<crate::AuthBindingRef>,
248    /// Whether this session runs as a keep-alive (long-running, interrupt-to-stop)
249    /// agent. Surfaces use this to decide blocking vs fire-and-return semantics.
250    pub keep_alive: bool,
251    /// Optional session checkpointer for keep-alive persistence.
252    pub checkpointer: Option<std::sync::Arc<dyn crate::checkpoint::SessionCheckpointer>>,
253    /// Comms intents that should be silently injected into the session
254    /// without triggering an LLM turn.
255    pub silent_comms_intents: Vec<String>,
256    /// Maximum peer-count threshold for inline peer lifecycle context injection.
257    ///
258    /// - `None`: use runtime default
259    /// - `0`: never inline peer lifecycle notifications
260    /// - `-1`: always inline peer lifecycle notifications
261    /// - `>0`: inline only when post-drain peer count is <= threshold
262    /// - `<-1`: invalid
263    pub max_inline_peer_notifications: Option<i32>,
264    /// Opaque application context passed through to custom `SessionAgentBuilder`
265    /// implementations. Not consumed by the standard build pipeline.
266    ///
267    /// Uses `Value` rather than `Box<RawValue>` because `SessionBuildOptions`
268    /// must be `Clone` and `Box<RawValue>` does not implement `Clone`.
269    /// Same tradeoff as `provider_params`.
270    pub app_context: Option<serde_json::Value>,
271    /// Additional instruction sections appended to the system prompt after skill
272    /// assembly, before tool instructions. Order preserved.
273    pub additional_instructions: Option<Vec<String>>,
274    /// Environment variables injected into shell tool subprocesses for this agent.
275    /// Set by the application's `SessionAgentBuilder` — never by the LLM.
276    /// Values are not included in the agent's context window.
277    pub shell_env: Option<std::collections::HashMap<String, String>>,
278    /// Explicit call-timeout override at the build seam.
279    ///
280    /// - `Inherit` (default): defer to config override, then profile default
281    /// - `Disabled`: explicitly disable call timeout regardless of profile
282    /// - `Value(d)`: explicitly set call timeout to `d`
283    pub call_timeout_override: crate::CallTimeoutOverride,
284    /// Typed explicit-override intent for resumed-session merges.
285    ///
286    /// Surfaces set bits only for fields they can prove were explicitly
287    /// supplied by the caller. Resumed metadata then fills only the
288    /// non-explicit fields.
289    pub resume_override_mask: ResumeOverrideMask,
290    /// Late-binding mob tool factory, called inside `build_agent()` with
291    /// session-scoped args to produce the mob tool dispatcher.
292    ///
293    /// Surfaces that enable mob tools pass an `Arc<dyn MobToolsFactory>` here.
294    /// The factory calls [`MobToolsFactory::build_mob_tools`] during agent
295    /// construction with the session ID, ops lifecycle registry, and optional
296    /// comms runtime — then composes the result into the tool gateway.
297    pub mob_tools: Option<Arc<dyn MobToolsFactory>>,
298    /// Runtime build mode — determines how the factory resolves the ops lifecycle
299    /// registry and completion feed.
300    ///
301    /// - `SessionOwned(bindings)`: runtime-backed build with epoch-owned
302    ///   bindings. Factory validates `bindings.session_id == session.id()`.
303    /// - `StandaloneEphemeral`: factory creates local-only ephemeral bindings.
304    ///   Suitable for WASM, tests, embedded, and standalone surfaces.
305    pub runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode,
306    /// Runtime-stamped metadata for an eager first turn.
307    ///
308    /// Session services only forward this carrier. They must not infer an
309    /// execution kind from runtime build mode.
310    pub initial_turn_metadata: Option<RuntimeTurnMetadata>,
311    /// Runtime-injected mob operator authority context.
312    ///
313    /// This is the only source of mob operator tool authority. Tool visibility
314    /// may depend on this context being present, but dispatch-time
315    /// authorization must still re-check the typed create/scope fields on
316    /// every operator call.
317    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
318}
319
320/// Opaque principal token carried through mob tool authority and provenance.
321///
322/// `meerkat-mob` may store or compare this token as an opaque blob, but it
323/// must not decode token structure, branch on token contents, or expand scope
324/// from it.
325#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
326pub struct OpaquePrincipalToken(String);
327
328impl OpaquePrincipalToken {
329    pub fn new(token: impl Into<String>) -> Self {
330        Self(token.into())
331    }
332
333    pub fn generated() -> Self {
334        Self(uuid::Uuid::new_v4().to_string())
335    }
336
337    pub fn as_str(&self) -> &str {
338        &self.0
339    }
340}
341
342impl std::fmt::Display for OpaquePrincipalToken {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.write_str(self.as_str())
345    }
346}
347
348/// Runtime-supplied caller provenance carried alongside mob tool authority.
349///
350/// This is informational/projection-only data. It is not a second authority
351/// source and must never be used for policy expansion inside `meerkat-mob`.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
353pub struct MobToolCallerProvenance {
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    caller_session_id: Option<crate::SessionId>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    caller_mob_id: Option<String>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    caller_member_id: Option<String>,
360}
361
362impl MobToolCallerProvenance {
363    pub fn new() -> Self {
364        Self::default()
365    }
366
367    pub fn with_session_id(mut self, session_id: crate::SessionId) -> Self {
368        self.caller_session_id = Some(session_id);
369        self
370    }
371
372    pub fn with_mob_id(mut self, mob_id: impl Into<String>) -> Self {
373        self.caller_mob_id = Some(mob_id.into());
374        self
375    }
376
377    pub fn with_member_id(mut self, member_id: impl Into<String>) -> Self {
378        self.caller_member_id = Some(member_id.into());
379        self
380    }
381
382    pub fn caller_session_id(&self) -> Option<&crate::SessionId> {
383        self.caller_session_id.as_ref()
384    }
385
386    pub fn caller_mob_id(&self) -> Option<&str> {
387        self.caller_mob_id.as_deref()
388    }
389
390    pub fn caller_member_id(&self) -> Option<&str> {
391        self.caller_member_id.as_deref()
392    }
393}
394
395/// Typed mob operator authority injected by the host/runtime.
396///
397/// This is capability-oriented only. It is not an identity or ownership
398/// model, and it must never be inferred from mob membership, session shape,
399/// `owner_session_id`, or profile flags.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct MobToolAuthorityContext {
402    principal_token: OpaquePrincipalToken,
403    can_create_mobs: bool,
404    #[serde(default)]
405    can_mutate_profiles: bool,
406    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
407    managed_mob_scope: BTreeSet<String>,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    caller_provenance: Option<MobToolCallerProvenance>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    audit_invocation_id: Option<String>,
412}
413
414impl MobToolAuthorityContext {
415    pub fn new(principal_token: OpaquePrincipalToken, can_create_mobs: bool) -> Self {
416        Self {
417            principal_token,
418            can_create_mobs,
419            can_mutate_profiles: can_create_mobs,
420            managed_mob_scope: BTreeSet::new(),
421            caller_provenance: None,
422            audit_invocation_id: None,
423        }
424    }
425
426    pub fn create_only_generated() -> Self {
427        Self::new(OpaquePrincipalToken::generated(), true)
428    }
429
430    pub fn principal_token(&self) -> &OpaquePrincipalToken {
431        &self.principal_token
432    }
433
434    pub fn can_create_mobs(&self) -> bool {
435        self.can_create_mobs
436    }
437
438    pub fn can_mutate_profiles(&self) -> bool {
439        self.can_mutate_profiles
440    }
441
442    pub fn with_profile_mutation(mut self, allowed: bool) -> Self {
443        self.can_mutate_profiles = allowed;
444        self
445    }
446
447    pub fn managed_mob_scope(&self) -> &BTreeSet<String> {
448        &self.managed_mob_scope
449    }
450
451    pub fn caller_provenance(&self) -> Option<&MobToolCallerProvenance> {
452        self.caller_provenance.as_ref()
453    }
454
455    pub fn audit_invocation_id(&self) -> Option<&str> {
456        self.audit_invocation_id.as_deref()
457    }
458
459    pub fn can_manage_mob(&self, mob_id: &str) -> bool {
460        self.managed_mob_scope.contains(mob_id)
461    }
462
463    pub fn grant_manage_mob(mut self, mob_id: impl Into<String>) -> Self {
464        self.managed_mob_scope.insert(mob_id.into());
465        self
466    }
467
468    /// Grant management scope for a mob in-place (mutable borrow).
469    ///
470    /// Used by the turn executor when applying `SessionEffect::GrantManageMob`
471    /// effects from tool dispatch.
472    pub fn grant_manage_mob_in_place(&mut self, mob_id: String) {
473        self.managed_mob_scope.insert(mob_id);
474    }
475
476    pub fn with_managed_mob_scope<I, S>(mut self, mob_ids: I) -> Self
477    where
478        I: IntoIterator<Item = S>,
479        S: Into<String>,
480    {
481        self.managed_mob_scope = mob_ids.into_iter().map(Into::into).collect();
482        self
483    }
484
485    pub fn with_caller_provenance(mut self, caller_provenance: MobToolCallerProvenance) -> Self {
486        self.caller_provenance = Some(caller_provenance);
487        self
488    }
489
490    pub fn with_audit_invocation_id(mut self, audit_invocation_id: impl Into<String>) -> Self {
491        self.audit_invocation_id = Some(audit_invocation_id.into());
492        self
493    }
494}
495
496/// Shared host/runtime policy for explicit mob-operator enablement.
497///
498/// When a host/runtime build seam explicitly enables mob operator tools for a
499/// session, the default authority shape is create-only. Existing-mob scope
500/// must still be injected separately and explicitly.
501pub fn generated_create_only_mob_operator_authority(
502    enable_mob: ToolCategoryOverride,
503) -> Option<MobToolAuthorityContext> {
504    matches!(enable_mob, ToolCategoryOverride::Enable)
505        .then(MobToolAuthorityContext::create_only_generated)
506}
507
508/// Shared build-seam rule for mob operator access rehydration.
509///
510/// Explicit disable clears authority. Otherwise, persisted typed authority
511/// wins; if none exists, explicit mob enablement falls back to generated
512/// create-only authority.
513pub fn resolve_mob_operator_access(
514    enable_mob: ToolCategoryOverride,
515    persisted_authority_context: Option<MobToolAuthorityContext>,
516) -> (ToolCategoryOverride, Option<MobToolAuthorityContext>) {
517    if matches!(enable_mob, ToolCategoryOverride::Disable) {
518        return (ToolCategoryOverride::Disable, None);
519    }
520
521    let authority_context = persisted_authority_context
522        .or_else(|| generated_create_only_mob_operator_authority(enable_mob));
523    let override_mob = if authority_context.is_some() {
524        ToolCategoryOverride::Enable
525    } else {
526        enable_mob
527    };
528
529    (override_mob, authority_context)
530}
531
532/// Provider of a snapshot of currently visible tools.
533///
534/// Implemented by the agent's `ToolScope` holder to capture tool visibility
535/// at spawn time for inheritance by mob children.
536pub trait VisibleToolSnapshotProvider: Send + Sync {
537    /// Returns the tool definitions currently visible to the parent agent.
538    fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>>;
539}
540
541/// Context for capturing a parent agent's tool scope snapshot.
542///
543/// `ParentOwned` carries a provider that can snapshot the parent's visible
544/// tools at child spawn time. `Standalone` means no parent scope is available
545/// (e.g. top-level agents, tests).
546pub enum MobToolSnapshotContext {
547    /// Parent agent owns a tool scope; snapshot available on demand.
548    ParentOwned(Arc<dyn VisibleToolSnapshotProvider>),
549    /// No parent scope available.
550    Standalone,
551}
552
553/// Session-scoped arguments passed to [`MobToolsFactory::build_mob_tools`].
554pub struct MobToolsBuildArgs {
555    /// Session ID of the agent being built.
556    pub session_id: crate::SessionId,
557    /// Model name of the owning agent — inherited by implicit mob helpers.
558    pub model: String,
559    /// Runtime-injected mob operator authority context.
560    ///
561    /// Tool visibility may depend on this context being present, but operator
562    /// dispatch must still re-check the typed create/scope fields on every
563    /// call.
564    pub authority_context: Option<MobToolAuthorityContext>,
565    /// Shared effective mob authority handle owned by the agent.
566    ///
567    /// Mob tools read from this handle for authorization checks. The agent
568    /// (turn owner) is the sole writer — it updates this handle via
569    /// `apply_session_effects` after merging tool-produced `SessionEffect`s.
570    /// If `None`, mob tools fall back to `authority_context` as a static snapshot.
571    pub effective_authority: Option<Arc<std::sync::RwLock<MobToolAuthorityContext>>>,
572    /// Comms name of the owning agent (for building `TrustedPeerDescriptor`).
573    pub comms_name: Option<String>,
574    /// Optional comms runtime for auto-wiring spawned members.
575    pub comms_runtime: Option<Arc<dyn crate::agent::CommsRuntime>>,
576    /// Context for capturing a snapshot of the parent agent's visible tools.
577    pub snapshot_context: MobToolSnapshotContext,
578}
579
580/// Factory trait for late-binding mob tool construction.
581///
582/// Implementations capture surface-specific state (e.g. `MobMcpState`) and
583/// receive session-scoped arguments from `build_agent()` at construction time.
584/// This avoids a cyclic dependency between the facade crate and `meerkat-mob-mcp`.
585#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
586#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
587pub trait MobToolsFactory: Send + Sync {
588    /// Build a mob tool dispatcher for the given session.
589    async fn build_mob_tools(
590        &self,
591        args: MobToolsBuildArgs,
592    ) -> Result<Arc<dyn AgentToolDispatcher>, Box<dyn std::error::Error + Send + Sync>>;
593}
594
595/// Typed explicit-override intent for resumed-session metadata merges.
596///
597/// This avoids trying to recover caller intent from flattened build config.
598#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
599pub struct ResumeOverrideMask {
600    pub model: bool,
601    pub provider: bool,
602    pub max_tokens: bool,
603    pub structured_output_retries: bool,
604    pub provider_params: bool,
605    pub auth_binding: bool,
606    pub override_builtins: bool,
607    pub override_shell: bool,
608    pub override_memory: bool,
609    pub override_mob: bool,
610    pub preload_skills: bool,
611    pub keep_alive: bool,
612    pub comms_name: bool,
613    pub peer_meta: bool,
614}
615
616impl SessionBuildOptions {
617    /// Apply the shared rehydration rule for mob operator access.
618    ///
619    /// This preserves exact persisted authority when available and otherwise
620    /// falls back to generated create-only authority for explicit mob
621    /// enablement.
622    pub fn apply_persisted_mob_operator_access(
623        &mut self,
624        enable_mob: ToolCategoryOverride,
625        persisted_authority_context: Option<MobToolAuthorityContext>,
626    ) {
627        let (override_mob, authority_context) =
628            resolve_mob_operator_access(enable_mob, persisted_authority_context);
629        self.override_mob = override_mob;
630        self.mob_tool_authority_context = authority_context;
631    }
632
633    /// Apply the shared host/runtime default for explicit mob operator
634    /// enablement.
635    ///
636    /// This keeps `override_mob` and the generated create-only authority
637    /// context aligned at the composition seam. Existing-mob scope must be
638    /// injected explicitly elsewhere; this helper never infers it.
639    pub fn apply_generated_create_only_mob_operator_access(
640        &mut self,
641        enable_mob: ToolCategoryOverride,
642    ) {
643        self.apply_persisted_mob_operator_access(enable_mob, None);
644    }
645}
646
647impl Default for SessionBuildOptions {
648    fn default() -> Self {
649        Self {
650            provider: None,
651            self_hosted_server_id: None,
652            output_schema: None,
653            structured_output_retries: 2,
654            hooks_override: HookRunOverrides::default(),
655            comms_name: None,
656            peer_meta: None,
657            resume_session: None,
658            // Phase 3 field — default None keeps the legacy flat path.
659            // Populated by surfaces that accept realm/binding inputs.
660            budget_limits: None,
661            provider_params: None,
662            external_tools: None,
663            recoverable_tool_defs: None,
664            blob_store_override: None,
665            llm_client_override: None,
666            override_builtins: ToolCategoryOverride::Inherit,
667            override_shell: ToolCategoryOverride::Inherit,
668            override_memory: ToolCategoryOverride::Inherit,
669            override_schedule: ToolCategoryOverride::Inherit,
670            override_mob: ToolCategoryOverride::Inherit,
671            schedule_tools: None,
672            preload_skills: None,
673            realm_id: None,
674            instance_id: None,
675            backend: None,
676            config_generation: None,
677            auth_binding: None,
678            keep_alive: false,
679            checkpointer: None,
680            silent_comms_intents: Vec::new(),
681            max_inline_peer_notifications: None,
682            app_context: None,
683            additional_instructions: None,
684            shell_env: None,
685            call_timeout_override: crate::CallTimeoutOverride::Inherit,
686            resume_override_mask: ResumeOverrideMask::default(),
687            mob_tools: None,
688            runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode::StandaloneEphemeral,
689            initial_turn_metadata: None,
690            mob_tool_authority_context: None,
691        }
692    }
693}
694
695impl std::fmt::Debug for SessionBuildOptions {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        f.debug_struct("SessionBuildOptions")
698            .field("provider", &self.provider)
699            .field("output_schema", &self.output_schema.is_some())
700            .field("structured_output_retries", &self.structured_output_retries)
701            .field("hooks_override", &self.hooks_override)
702            .field("comms_name", &self.comms_name)
703            .field("peer_meta", &self.peer_meta)
704            .field("resume_session", &self.resume_session.is_some())
705            .field("budget_limits", &self.budget_limits)
706            .field("provider_params", &self.provider_params.is_some())
707            .field("external_tools", &self.external_tools.is_some())
708            .field("recoverable_tool_defs", &self.recoverable_tool_defs)
709            .field("blob_store_override", &self.blob_store_override.is_some())
710            .field("llm_client_override", &self.llm_client_override.is_some())
711            .field("override_builtins", &self.override_builtins)
712            .field("override_shell", &self.override_shell)
713            .field("override_memory", &self.override_memory)
714            .field("override_schedule", &self.override_schedule)
715            .field("override_mob", &self.override_mob)
716            .field("schedule_tools", &self.schedule_tools.is_some())
717            .field("preload_skills", &self.preload_skills)
718            .field("realm_id", &self.realm_id)
719            .field("instance_id", &self.instance_id)
720            .field("backend", &self.backend)
721            .field("config_generation", &self.config_generation)
722            .field("keep_alive", &self.keep_alive)
723            .field("checkpointer", &self.checkpointer.is_some())
724            .field("silent_comms_intents", &self.silent_comms_intents)
725            .field(
726                "max_inline_peer_notifications",
727                &self.max_inline_peer_notifications,
728            )
729            .field("app_context", &self.app_context.is_some())
730            .field("additional_instructions", &self.additional_instructions)
731            .field("call_timeout_override", &self.call_timeout_override)
732            .field("resume_override_mask", &self.resume_override_mask)
733            .field("mob_tools", &self.mob_tools.is_some())
734            .field("runtime_build_mode", &self.runtime_build_mode)
735            .field(
736                "initial_turn_metadata",
737                &self.initial_turn_metadata.is_some(),
738            )
739            .field(
740                "mob_tool_authority_context",
741                &self.mob_tool_authority_context.is_some(),
742            )
743            .field("runtime_build_mode", &self.runtime_build_mode)
744            .finish()
745    }
746}
747
748/// Request to start a new turn on an existing session.
749#[derive(Debug)]
750pub struct StartTurnRequest {
751    /// User prompt for this turn (text or multimodal).
752    pub prompt: ContentInput,
753    /// Optional system prompt override for a deferred session's first turn.
754    ///
755    /// This is only supported before the session has any conversation history.
756    /// Materialized sessions with existing messages must reject it.
757    pub system_prompt: Option<String>,
758    /// Optional normalized rendering metadata for this turn prompt.
759    pub render_metadata: Option<RenderMetadata>,
760    /// Handling mode for this turn's ordinary content-bearing work.
761    ///
762    /// This is a **runtime-owned semantic**: the runtime routes Queue/Steer
763    /// before calling the executor. The session service passes this through
764    /// to the `SessionAgent` but does not act on it. Non-Queue handling
765    /// only works correctly on runtime-backed surfaces.
766    pub handling_mode: HandlingMode,
767    /// Channel for streaming events during the turn.
768    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
769    /// Canonical SkillKeys to resolve and inject for this turn.
770    pub skill_references: Option<Vec<crate::skills::SkillKey>>,
771    /// Optional per-turn flow tool overlay (ephemeral, non-persistent).
772    pub flow_tool_overlay: Option<TurnToolOverlay>,
773    /// Runtime-owned system-context appends that must be applied at this
774    /// turn boundary before the model run starts.
775    pub pre_turn_context_appends: Vec<PendingSystemContextAppend>,
776    /// Canonical runtime-authored metadata for this turn.
777    ///
778    /// Runtime-backed callers populate this once at the machine boundary and
779    /// the session layer derives per-turn policy from this typed carrier
780    /// instead of re-inferring or dropping fields.
781    pub turn_metadata: Option<RuntimeTurnMetadata>,
782}
783
784/// Request to append runtime system context to an existing session.
785#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
786pub struct AppendSystemContextRequest {
787    pub text: String,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub source: Option<String>,
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub idempotency_key: Option<String>,
792}
793
794/// Result of appending runtime system context to a session.
795#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
796pub struct AppendSystemContextResult {
797    pub status: AppendSystemContextStatus,
798}
799
800/// Request to stage callback tool results for the next turn.
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct StageToolResultsRequest {
803    pub results: Vec<crate::ToolResult>,
804}
805
806/// Result of staging callback tool results for the next turn.
807#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
808pub struct StageToolResultsResult {
809    pub accepted_result_count: usize,
810}
811
812/// Outcome of an append-system-context request.
813#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
814#[serde(rename_all = "snake_case")]
815pub enum AppendSystemContextStatus {
816    Applied,
817    Staged,
818    Duplicate,
819}
820
821/// Ephemeral per-turn tool overlay for flow-dispatched turns.
822#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
823#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
824pub struct TurnToolOverlay {
825    /// Optional allow-list for this turn.
826    #[serde(default)]
827    pub allowed_tools: Option<Vec<String>>,
828    /// Optional deny-list for this turn.
829    #[serde(default)]
830    pub blocked_tools: Option<Vec<String>>,
831}
832
833/// Query parameters for listing sessions.
834#[derive(Debug, Default)]
835pub struct SessionQuery {
836    /// Maximum number of results.
837    pub limit: Option<usize>,
838    /// Offset for pagination.
839    pub offset: Option<usize>,
840    /// Filters sessions where all specified k/v pairs match.
841    pub labels: Option<BTreeMap<String, String>>,
842}
843
844/// Summary of a session (for list results).
845///
846/// Kept lightweight — no billing data. Use `read()` for full details.
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct SessionSummary {
849    pub session_id: SessionId,
850    pub created_at: SystemTime,
851    pub updated_at: SystemTime,
852    pub message_count: usize,
853    pub total_tokens: u64,
854    pub is_active: bool,
855    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
856    pub labels: BTreeMap<String, String>,
857}
858
859/// Detailed view of a session's state and history metadata.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct SessionInfo {
862    pub session_id: SessionId,
863    pub created_at: SystemTime,
864    pub updated_at: SystemTime,
865    pub message_count: usize,
866    pub is_active: bool,
867    pub model: String,
868    pub provider: Provider,
869    pub last_assistant_text: Option<String>,
870    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
871    pub labels: BTreeMap<String, String>,
872}
873
874/// Billing/usage data for a session, returned separately from state.
875#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct SessionUsage {
877    pub total_tokens: u64,
878    pub usage: Usage,
879}
880
881/// Combined session view (state + usage). Convenience wrapper used by
882/// `SessionService::read()` to avoid requiring two calls.
883#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct SessionView {
885    pub state: SessionInfo,
886    pub billing: SessionUsage,
887}
888
889impl SessionView {
890    /// Convenience: session ID from the state.
891    pub fn session_id(&self) -> &SessionId {
892        &self.state.session_id
893    }
894}
895
896/// Query parameters for reading session history.
897#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
898pub struct SessionHistoryQuery {
899    /// Number of messages to skip from the start of the transcript.
900    pub offset: usize,
901    /// Maximum number of messages to return.
902    #[serde(default, skip_serializing_if = "Option::is_none")]
903    pub limit: Option<usize>,
904}
905
906/// Paginated transcript page for a session.
907#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct SessionHistoryPage {
909    pub session_id: SessionId,
910    pub message_count: usize,
911    pub offset: usize,
912    #[serde(default, skip_serializing_if = "Option::is_none")]
913    pub limit: Option<usize>,
914    pub has_more: bool,
915    pub messages: Vec<Message>,
916}
917
918impl SessionHistoryPage {
919    /// Build a transcript page from the full ordered message list.
920    pub fn from_messages(
921        session_id: SessionId,
922        messages: &[Message],
923        query: SessionHistoryQuery,
924    ) -> Self {
925        let message_count = messages.len();
926        let start = query.offset.min(message_count);
927        let end = match query.limit {
928            Some(limit) => start.saturating_add(limit).min(message_count),
929            None => message_count,
930        };
931        Self {
932            session_id,
933            message_count,
934            offset: start,
935            limit: query.limit,
936            has_more: end < message_count,
937            messages: messages[start..end].to_vec(),
938        }
939    }
940}
941
942/// Canonical session lifecycle abstraction.
943///
944/// All surfaces delegate to this trait. Implementations control persistence,
945/// compaction, and event logging behavior.
946#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
947#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
948pub trait SessionService: Send + Sync {
949    /// Create a new session and run the first turn.
950    async fn create_session(&self, req: CreateSessionRequest) -> Result<RunResult, SessionError>;
951
952    /// Start a new turn on an existing session.
953    async fn start_turn(
954        &self,
955        id: &SessionId,
956        req: StartTurnRequest,
957    ) -> Result<RunResult, SessionError>;
958
959    /// Cancel an in-flight turn.
960    ///
961    /// Returns `NotRunning` if no turn is active.
962    async fn interrupt(&self, id: &SessionId) -> Result<(), SessionError>;
963
964    /// Cancel an in-flight turn once it reaches the next boundary.
965    ///
966    /// Returns `NotRunning` if no turn is active. Unsupported by default.
967    async fn cancel_after_boundary(&self, _id: &SessionId) -> Result<(), SessionError> {
968        Err(SessionError::Unsupported(
969            "cancel_after_boundary".to_string(),
970        ))
971    }
972
973    /// Replace the LLM client on a live session.
974    ///
975    /// Enables mid-session model/provider hot-swap without rebuilding the
976    /// agent. The new client takes effect on the next turn. Returns
977    /// `Unsupported` by default; session services that support live agents
978    /// override this.
979    async fn set_session_client(
980        &self,
981        _id: &SessionId,
982        _client: std::sync::Arc<dyn crate::AgentLlmClient>,
983    ) -> Result<(), SessionError> {
984        Err(SessionError::Unsupported("set_session_client".to_string()))
985    }
986
987    /// Atomically replace the live session client and the session's durable
988    /// LLM identity.
989    ///
990    /// This is the canonical seam for materialized-session hot-swap semantics.
991    /// Implementations should apply both updates together so future turns and
992    /// resume/recovery see the same model/provider/provider_params identity.
993    async fn hot_swap_session_llm_identity(
994        &self,
995        _id: &SessionId,
996        _client: std::sync::Arc<dyn crate::AgentLlmClient>,
997        _identity: SessionLlmIdentity,
998        _request_policy: crate::SessionLlmRequestPolicy,
999    ) -> Result<(), SessionError> {
1000        Err(SessionError::Unsupported(
1001            "hot_swap_session_llm_identity".to_string(),
1002        ))
1003    }
1004
1005    /// Replace the canonical tool visibility state carried by the live session.
1006    ///
1007    /// This seam is live-only and must not perform its own durable write. The
1008    /// caller owns any surrounding transactional persistence and rollback.
1009    async fn set_session_tool_visibility_state(
1010        &self,
1011        _id: &SessionId,
1012        _state: Option<crate::SessionToolVisibilityState>,
1013    ) -> Result<(), SessionError> {
1014        Err(SessionError::Unsupported(
1015            "set_session_tool_visibility_state".to_string(),
1016        ))
1017    }
1018
1019    /// Update the `keep_alive` flag on a live session's durable metadata.
1020    ///
1021    /// Called by the runtime when an explicit override changes the session's
1022    /// keep-alive intent so that subsequent inheriting calls observe the
1023    /// updated value. Returns `Unsupported` by default.
1024    async fn update_session_keep_alive(
1025        &self,
1026        _id: &SessionId,
1027        _keep_alive: bool,
1028    ) -> Result<(), SessionError> {
1029        Err(SessionError::Unsupported(
1030            "update_session_keep_alive".to_string(),
1031        ))
1032    }
1033
1034    /// Update the session's canonical mob operator authority context.
1035    ///
1036    /// This is the only supported seam for widening or narrowing exact mob
1037    /// management scope after session creation so recovery and live runtime
1038    /// state stay aligned.
1039    async fn update_session_mob_authority_context(
1040        &self,
1041        _id: &SessionId,
1042        _authority_context: Option<MobToolAuthorityContext>,
1043    ) -> Result<(), SessionError> {
1044        Err(SessionError::Unsupported(
1045            "update_session_mob_authority_context".to_string(),
1046        ))
1047    }
1048
1049    /// Whether a live in-memory session bridge currently exists for `id`.
1050    ///
1051    /// This is intentionally distinct from `list()` / `SessionSummary`:
1052    /// persisted-only summaries must not count as live, and idle live sessions
1053    /// must still count as live even when no turn is running.
1054    async fn has_live_session(&self, _id: &SessionId) -> Result<bool, SessionError> {
1055        Err(SessionError::Unsupported("has_live_session".to_string()))
1056    }
1057
1058    /// Stage an external tool visibility filter on a live session.
1059    ///
1060    /// Used to dynamically hide/show tools (e.g., `view_image`) after a
1061    /// model hot-swap changes capability support. Returns `Unsupported`
1062    /// by default.
1063    async fn set_session_tool_filter(
1064        &self,
1065        _id: &SessionId,
1066        _filter: crate::ToolFilter,
1067    ) -> Result<(), SessionError> {
1068        Err(SessionError::Unsupported(
1069            "set_session_tool_filter".to_string(),
1070        ))
1071    }
1072
1073    /// Read the current state of a session.
1074    async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError>;
1075
1076    /// List sessions matching the query.
1077    async fn list(&self, query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError>;
1078
1079    /// Archive (remove) a session.
1080    async fn archive(&self, id: &SessionId) -> Result<(), SessionError>;
1081
1082    /// Subscribe to session-wide events regardless of triggering interaction.
1083    ///
1084    /// Services that do not support this capability return `StreamError::NotFound`.
1085    async fn subscribe_session_events(&self, id: &SessionId) -> Result<EventStream, StreamError> {
1086        Err(StreamError::NotFound(format!("session {id}")))
1087    }
1088}
1089
1090/// Optional comms/control-plane extension for `SessionService`.
1091///
1092/// Base lifecycle operations stay on `SessionService`; advanced surfaces
1093/// (RPC/REST/mob orchestration) can use this trait when they need direct
1094/// access to comms runtime and injector handles.
1095#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1096#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1097pub trait SessionServiceCommsExt: SessionService {
1098    /// Get the comms runtime for a session, if available.
1099    async fn comms_runtime(
1100        &self,
1101        _session_id: &SessionId,
1102    ) -> Option<Arc<dyn crate::agent::CommsRuntime>> {
1103        None
1104    }
1105
1106    /// Get the event injector for a session, if available.
1107    async fn event_injector(
1108        &self,
1109        session_id: &SessionId,
1110    ) -> Option<Arc<dyn crate::EventInjector>> {
1111        self.comms_runtime(session_id)
1112            .await
1113            .and_then(|runtime| runtime.event_injector())
1114    }
1115
1116    /// Internal runtime seam for interaction-scoped injection.
1117    #[doc(hidden)]
1118    async fn interaction_event_injector(
1119        &self,
1120        session_id: &SessionId,
1121    ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1122        self.comms_runtime(session_id)
1123            .await
1124            .and_then(|runtime| runtime.interaction_event_injector())
1125    }
1126}
1127
1128/// Optional control-plane extension for `SessionService`.
1129///
1130/// Keeps the base lifecycle contract minimal while exposing first-class
1131/// session mutation operations shared across external surfaces.
1132#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1133#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1134pub trait SessionServiceControlExt: SessionService {
1135    /// Append runtime system context to a session.
1136    ///
1137    /// The request is idempotent per `(session_id, idempotency_key)`. When a
1138    /// turn is active, implementations may stage the append for application at
1139    /// the next LLM boundary rather than mutating in-flight request state.
1140    async fn append_system_context(
1141        &self,
1142        id: &SessionId,
1143        req: AppendSystemContextRequest,
1144    ) -> Result<AppendSystemContextResult, SessionControlError>;
1145
1146    /// Stage callback tool results for application on the next turn seam.
1147    ///
1148    /// Implementations must persist the staged results durably before a live
1149    /// session can observe them so a failed call never leaves hidden pending
1150    /// transcript mutations behind.
1151    async fn stage_tool_results(
1152        &self,
1153        id: &SessionId,
1154        req: StageToolResultsRequest,
1155    ) -> Result<StageToolResultsResult, SessionError> {
1156        let _ = (id, req);
1157        Err(SessionError::Unsupported("stage_tool_results".to_string()))
1158    }
1159}
1160
1161/// Optional history-read extension for `SessionService`.
1162///
1163/// Keeps the base lifecycle contract lightweight while allowing surfaces to
1164/// fetch full transcript contents when they explicitly opt in.
1165#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1166#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1167pub trait SessionServiceHistoryExt: SessionService {
1168    /// Read the committed transcript for a session.
1169    ///
1170    /// Implementations may return `PersistenceDisabled` if they cannot provide
1171    /// authoritative history for the requested lifecycle state.
1172    async fn read_history(
1173        &self,
1174        id: &SessionId,
1175        query: SessionHistoryQuery,
1176    ) -> Result<SessionHistoryPage, SessionError>;
1177}
1178
1179/// Extension trait for `Arc<dyn SessionService>` to allow calling methods directly.
1180impl dyn SessionService {
1181    /// Wrap self in an Arc.
1182    pub fn into_arc(self: Box<Self>) -> Arc<dyn SessionService> {
1183        Arc::from(self)
1184    }
1185}
1186
1187#[cfg(test)]
1188#[allow(
1189    clippy::unimplemented,
1190    clippy::unwrap_used,
1191    clippy::expect_used,
1192    clippy::panic
1193)]
1194mod tests {
1195    use super::*;
1196
1197    struct UnsupportedSessionService;
1198
1199    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1200    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1201    impl SessionService for UnsupportedSessionService {
1202        async fn create_session(
1203            &self,
1204            _req: CreateSessionRequest,
1205        ) -> Result<RunResult, SessionError> {
1206            unimplemented!()
1207        }
1208
1209        async fn start_turn(
1210            &self,
1211            _id: &SessionId,
1212            _req: StartTurnRequest,
1213        ) -> Result<RunResult, SessionError> {
1214            unimplemented!()
1215        }
1216
1217        async fn interrupt(&self, _id: &SessionId) -> Result<(), SessionError> {
1218            unimplemented!()
1219        }
1220
1221        async fn read(&self, _id: &SessionId) -> Result<SessionView, SessionError> {
1222            unimplemented!()
1223        }
1224
1225        async fn list(&self, _query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError> {
1226            unimplemented!()
1227        }
1228
1229        async fn archive(&self, _id: &SessionId) -> Result<(), SessionError> {
1230            unimplemented!()
1231        }
1232    }
1233
1234    #[tokio::test]
1235    async fn has_live_session_defaults_to_unsupported() {
1236        let service = UnsupportedSessionService;
1237        let err = service
1238            .has_live_session(&SessionId::new())
1239            .await
1240            .expect_err("default implementation should fail loudly");
1241        assert!(matches!(err, SessionError::Unsupported(name) if name == "has_live_session"));
1242    }
1243
1244    #[test]
1245    fn grant_manage_mob_in_place_adds_mob_id() {
1246        let mut ctx = MobToolAuthorityContext::create_only_generated();
1247        ctx.grant_manage_mob_in_place("mob-1".into());
1248        assert!(ctx.managed_mob_scope.contains("mob-1"));
1249    }
1250
1251    #[test]
1252    fn grant_manage_mob_in_place_is_idempotent() {
1253        let mut ctx = MobToolAuthorityContext::create_only_generated();
1254        ctx.grant_manage_mob_in_place("mob-1".into());
1255        ctx.grant_manage_mob_in_place("mob-1".into());
1256        assert_eq!(ctx.managed_mob_scope.len(), 1);
1257    }
1258
1259    #[test]
1260    fn grant_manage_mob_in_place_accumulates() {
1261        let mut ctx = MobToolAuthorityContext::create_only_generated();
1262        ctx.grant_manage_mob_in_place("mob-1".into());
1263        ctx.grant_manage_mob_in_place("mob-2".into());
1264        assert!(ctx.managed_mob_scope.contains("mob-1"));
1265        assert!(ctx.managed_mob_scope.contains("mob-2"));
1266        assert_eq!(ctx.managed_mob_scope.len(), 2);
1267    }
1268
1269    struct MockSnapshotProvider {
1270        tools: Vec<Arc<ToolDef>>,
1271    }
1272
1273    impl VisibleToolSnapshotProvider for MockSnapshotProvider {
1274        fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>> {
1275            self.tools.clone()
1276        }
1277    }
1278
1279    #[test]
1280    fn mob_tool_snapshot_context_standalone() {
1281        let ctx = MobToolSnapshotContext::Standalone;
1282        assert!(matches!(ctx, MobToolSnapshotContext::Standalone));
1283    }
1284
1285    #[test]
1286    fn mob_tool_snapshot_context_parent_owned_returns_tools() {
1287        let tools = vec![Arc::new(ToolDef {
1288            name: "test_tool".into(),
1289            description: "a test".to_string(),
1290            input_schema: serde_json::json!({"type": "object"}),
1291            provenance: None,
1292        })];
1293        let provider = Arc::new(MockSnapshotProvider { tools });
1294        let ctx = MobToolSnapshotContext::ParentOwned(provider);
1295        match ctx {
1296            MobToolSnapshotContext::ParentOwned(p) => {
1297                let snapshot = p.snapshot_visible_tools();
1298                assert_eq!(snapshot.len(), 1);
1299                assert_eq!(snapshot[0].name, "test_tool");
1300            }
1301            MobToolSnapshotContext::Standalone => panic!("expected ParentOwned"),
1302        }
1303    }
1304}