Skip to main content

gemini_adk_fluent_rs/spec/
skills.rs

1//! Reusable task definitions and model-free task journeys.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use gemini_adk_rs::flow::{Enforcement, Flow};
7use gemini_adk_rs::state::State;
8use gemini_adk_rs::tasks::{
9    CompiledSkill, TaskEffect, TaskEffects, TaskPattern, TaskServices, TaskServicesFactory,
10    TaskTool, TaskToolEffect, TaskWatcher,
11};
12use gemini_adk_rs::tool::{ContextTool, ToolContext, ToolDispatcher, ToolFunction};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16use super::{SessionSpec, SpecResources, SpecTest, StateFieldSpec, StateType, ToolSpec};
17
18/// One skill-local operation. Its effect policy is mandatory even for mocks.
19#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
20pub struct SkillToolSpec {
21    /// Declaration and external or mock binding.
22    #[serde(flatten)]
23    pub tool: ToolSpec,
24    /// Reads can run immediately; commits require a trusted approval.
25    pub effect: TaskToolEffect,
26}
27
28/// A versioned capability installed in one speaking session.
29#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
30pub struct SkillSpec {
31    /// Unique installed capability name.
32    pub name: String,
33    /// Version pinned by each activation.
34    pub version: String,
35    /// Short routing description, available before activation.
36    #[serde(default)]
37    pub description: String,
38    /// Instructions projected when this capability is foreground.
39    #[serde(default)]
40    pub instruction: String,
41    /// Typed activation fields. Fields without defaults are required.
42    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43    pub inputs: BTreeMap<String, StateFieldSpec>,
44    /// Fields exported from accepted task state on completion.
45    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
46    pub outputs: BTreeMap<String, StateFieldSpec>,
47    /// Task-private state and defaults.
48    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49    pub state: BTreeMap<String, StateFieldSpec>,
50    /// Operations, using skill-local names.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub tools: Vec<SkillToolSpec>,
53    /// Structured extraction from this activation's owned transcript.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub extract: Vec<super::ExtractSpec>,
56    /// Derived task state, evaluated in dependency order.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub computed: Vec<super::ComputedSpec>,
59    /// Reactions to accepted task-state changes.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub watch: Vec<super::WatchSpec>,
62    /// Patterns over this activation's turns or clock.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub patterns: Vec<super::PatternSpec>,
65    /// Existing contextual memory projected into this task's state.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub memory: Option<super::MemorySpec>,
68    /// Optional lower-level governed flow; mutually exclusive with conversation.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub flow: Option<Flow>,
71    /// Optional stages, digressions, repair and policies.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub conversation: Option<crate::conversation::ConversationSpec>,
74    /// Existing model-free tests for the governed workflow.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub tests: Vec<SpecTest>,
77    /// A code tool for this skill, declared as `{skill}__run_code` while the
78    /// skill is foreground: a program whose functions are the skill's tools
79    /// by their local names. Without `functions`, every read tool that the
80    /// skill's flow does not guard as a commit.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub code: Option<super::CodeSpec>,
83}
84
85impl SkillSpec {
86    pub(super) fn definition(&self) -> SessionSpec {
87        let mut state = self.state.clone();
88        state.extend(self.inputs.clone());
89        for (name, field) in &self.outputs {
90            state.entry(name.clone()).or_insert_with(|| field.clone());
91        }
92        SessionSpec {
93            name: self.name.clone(),
94            version: self.version.clone(),
95            description: self.description.clone(),
96            instruction: self.instruction.clone(),
97            state,
98            tools: self.tools.iter().map(|t| t.tool.clone()).collect(),
99            extract: self.extract.clone(),
100            computed: self.computed.clone(),
101            watch: self.watch.clone(),
102            patterns: self.patterns.clone(),
103            memory: self.memory.clone(),
104            flow: self.flow.clone(),
105            conversation: self.conversation.clone(),
106            tests: self.tests.clone(),
107            ..Default::default()
108        }
109        .with_skill_code(self)
110    }
111
112    /// Validate identity, field contracts, effect policies and governance.
113    pub fn validate(&self) -> Result<(), Vec<String>> {
114        self.validate_with_http_support(true)
115    }
116
117    pub(super) fn validate_with_http_support(&self, http_support: bool) -> Result<(), Vec<String>> {
118        let validation = self.validation(http_support);
119        if validation.valid {
120            Ok(())
121        } else {
122            Err(validation.errors)
123        }
124    }
125
126    pub(super) fn validation(&self, http_support: bool) -> super::SpecValidation {
127        let mut errors = Vec::new();
128        if let Err(error) = gemini_adk_rs::tasks::validate_skill_identity(&self.name, &self.version)
129        {
130            errors.push(error.to_string());
131        }
132        let mut names = BTreeSet::new();
133        for tool in &self.tools {
134            if let Err(error) = task_parameters(tool) {
135                errors.push(error);
136            }
137            if tool.tool.background || tool.tool.scheduling.is_some() {
138                errors.push(format!(
139                    "skill tool '{}' uses task-owned asynchronous delivery; background and scheduling overrides are unsupported",
140                    tool.tool.name
141                ));
142            }
143            if tool.tool.name.trim().is_empty() || !names.insert(tool.tool.name.as_str()) {
144                errors.push(format!(
145                    "skill '{}' has an empty or duplicate tool name '{}'",
146                    self.name, tool.tool.name
147                ));
148            }
149            if let Err(error) = (TaskTool {
150                name: tool.tool.name.clone(),
151                description: tool.tool.description.clone(),
152                parameters: tool.tool.parameters.clone(),
153                effect: tool.effect.clone(),
154            })
155            .validate_for_skill(&self.name)
156            {
157                errors.push(error.to_string());
158            }
159        }
160        // Computed outputs own derived: storage; every other authored writer
161        // must stay outside the task runtime's reserved namespaces.
162        let mut writers = self.definition();
163        writers.computed.clear();
164        for key in writers
165            .state_keys_written()
166            .into_iter()
167            .chain(self.computed.iter().map(|value| value.key.clone()))
168        {
169            if key.trim().is_empty()
170                || ["task:", "flow:", "derived:"]
171                    .iter()
172                    .any(|prefix| key.starts_with(prefix))
173            {
174                errors.push(format!(
175                    "skill '{}' writes empty or runtime-owned state key '{key}'",
176                    self.name
177                ));
178            }
179        }
180        let mut extractor_names = BTreeSet::new();
181        for extractor in &self.extract {
182            if extractor.name.trim().is_empty() || !extractor_names.insert(&extractor.name) {
183                errors.push(format!(
184                    "skill '{}' has an empty or duplicate extractor name '{}'",
185                    self.name, extractor.name
186                ));
187            }
188            if extractor.window == 0 {
189                errors.push(format!(
190                    "task extractor '{}' needs a nonzero transcript window",
191                    extractor.name
192                ));
193            }
194            if extractor.trigger == super::TriggerSpec::OnPhaseChange {
195                errors.push(format!("task extractor '{}' cannot use on_phase_change: task skills do not have phases", extractor.name));
196            }
197        }
198        if self.memory.is_some() {
199            for name in super::MEMORY_TOOL_NAMES {
200                if names.contains(name) {
201                    errors.push(format!(
202                        "task memory owns tool '{name}'; do not redeclare it"
203                    ));
204                }
205            }
206        }
207        for (scope, fields) in [
208            ("input", &self.inputs),
209            ("output", &self.outputs),
210            ("state", &self.state),
211        ] {
212            for (name, field) in fields {
213                if name.trim().is_empty()
214                    || name.starts_with("flow:")
215                    || name.starts_with("task:")
216                    || name.starts_with("derived:")
217                {
218                    errors.push(format!(
219                        "{scope} field '{name}' uses an empty or runtime-owned name"
220                    ));
221                }
222                if scope != "state" && field.kind.is_none() {
223                    errors.push(format!("{scope} field '{name}' needs a declared type"));
224                }
225                if let (Some(kind), Some(default)) = (field.kind, &field.default)
226                    && !kind.matches(default)
227                {
228                    errors.push(format!(
229                        "{scope} field '{name}' has a default of the wrong type"
230                    ));
231                }
232                for other in [&self.state, &self.inputs, &self.outputs] {
233                    if let Some(other) = other.get(name) {
234                        if other.kind != field.kind {
235                            errors.push(format!(
236                                "field '{name}' has conflicting types across contracts"
237                            ));
238                        }
239                        if matches!((&other.default, &field.default), (Some(a), Some(b)) if a != b)
240                        {
241                            errors.push(format!(
242                                "field '{name}' has conflicting defaults across contracts"
243                            ));
244                        }
245                    }
246                }
247            }
248        }
249        if let Some(conversation) = &self.conversation {
250            for stage in conversation.stages.iter().chain(
251                conversation
252                    .overlays
253                    .iter()
254                    .flat_map(|overlay| overlay.stages.iter()),
255            ) {
256                if stage.frame.is_some() || !stage.resolve.is_empty() {
257                    errors.push(format!("stage '{}' requires stage frame extraction or resolvers, which task skills do not yet support; use skill-level extract for owned speech fields", stage.id));
258                }
259                if stage.verbatim.is_some() || stage.timing.is_some() {
260                    errors.push(format!("stage '{}' requires task-owned transcript or voice timing projection, which task skills do not yet support", stage.id));
261                }
262            }
263            for policy in &conversation.policies {
264                match policy {
265                    crate::policy::Policy::Redact { .. } => {
266                        errors.push("task skills do not yet provide redaction across input steering, approvals and results; redact policies are unsupported".into());
267                    }
268                    crate::policy::Policy::Commit { tool, .. }
269                        if !self.tools.iter().any(|t| {
270                            t.tool.name == *tool
271                                && matches!(t.effect, TaskToolEffect::Commit { .. })
272                        }) =>
273                    {
274                        errors.push(format!(
275                            "commit policy '{tool}' needs a commit effect declaration"
276                        ));
277                    }
278                    _ => {}
279                }
280            }
281        }
282        if let Some(code) = &self.code {
283            for name in &code.functions {
284                if self.tools.iter().any(|t| {
285                    &t.tool.name == name && matches!(t.effect, TaskToolEffect::Commit { .. })
286                }) {
287                    errors.push(format!(
288                        "skill '{}' code: '{name}' is a commit, which waits for approval in \
289                         the application; a program cannot wait for it, so leave it out of \
290                         code.functions",
291                        self.name
292                    ));
293                }
294            }
295        }
296        if !self.tests.is_empty() && self.flow.is_none() && self.conversation.is_none() {
297            errors.push("skill tests require a governed flow or conversation; use task_scenarios for lifecycle checks".into());
298        }
299        let mut validation = self
300            .definition()
301            .validate_definition(http_support, &self.inputs.keys().cloned().collect());
302        errors.extend(validation.errors);
303        validation.valid = errors.is_empty();
304        validation.errors = errors;
305        if self.memory.is_some() {
306            for name in super::MEMORY_TOOL_NAMES {
307                if !validation.tools.iter().any(|tool| tool == name) {
308                    validation.tools.push(name.into());
309                }
310            }
311        }
312        validation
313    }
314
315    /// Run the existing governed-workflow tests. Task approvals and lifecycle
316    /// require session task scenarios, which exercise the task runtime.
317    pub fn run_tests(&self) -> Vec<super::TestReport> {
318        self.definition().run_tests()
319    }
320
321    /// Compile independent activation governance and invocation-local tool bindings.
322    pub fn compile(&self, resources: &SpecResources) -> Result<CompiledSkill, String> {
323        self.compile_mode(resources, false, &super::ModelsSpec::default())
324    }
325
326    /// `models` is the session's, for extract entries that name no model or
327    /// thinking budget.
328    pub(super) fn compile_mode(
329        &self,
330        resources: &SpecResources,
331        offline: bool,
332        models: &super::ModelsSpec,
333    ) -> Result<CompiledSkill, String> {
334        self.validate_with_http_support(!offline)
335            .map_err(|e| e.join("; "))?;
336        let definition = self.definition();
337        let services = self.compile_services(resources, offline, models)?;
338        let memory_enabled = self.memory.is_some();
339        let flow_factory: Option<gemini_adk_rs::tasks::FlowFactory> =
340            if let Some(conversation) = &self.conversation {
341                let compiled = crate::conversation::Conversation::from_spec_stubbing_resolvers(
342                    conversation.clone(),
343                )
344                .map_err(|e| e.to_string())?;
345                Some(Arc::new(move || {
346                    let stack = compiled.stack(Enforcement::Enforce);
347                    Ok(if memory_enabled {
348                        stack.with_ambient_tools(super::MEMORY_TOOL_NAMES.map(str::to_owned))
349                    } else {
350                        stack
351                    })
352                }))
353            } else if let Some(flow) = &self.flow {
354                let mut flow = flow.clone();
355                if memory_enabled {
356                    flow.ambient
357                        .extend(super::MEMORY_TOOL_NAMES.map(str::to_owned));
358                }
359                let compiled = flow.compile().map_err(|e| e.to_string())?;
360                Some(Arc::new(move || {
361                    Ok(gemini_adk_rs::flow::FlowStack::new(
362                        compiled.clone(),
363                        Enforcement::Enforce,
364                    ))
365                }))
366            } else {
367                None
368            };
369        let mut initial_state = std::collections::HashMap::new();
370        for (key, field) in &definition.state {
371            if let Some(value) = &field.default {
372                initial_state.insert(key.clone(), value.clone());
373            }
374        }
375        let input_fields = self.inputs.clone();
376        let output_fields = self.outputs.clone();
377        let projected_fields = self.outputs.clone();
378        let receipt_tools: BTreeMap<_, _> = self
379            .tools
380            .iter()
381            .map(|declaration| (declaration.tool.name.clone(), declaration.tool.clone()))
382            .collect();
383        let mut skill = self.clone();
384        let memory_tools = if self.memory.is_some() && !offline {
385            resources
386                .memory
387                .as_ref()
388                .ok_or("task memory requires SpecResources.memory")?
389                .task_tools()
390        } else {
391            Vec::new()
392        };
393        if self.memory.is_some() {
394            for name in super::MEMORY_TOOL_NAMES {
395                let implementation = memory_tools.iter().find(|tool| tool.name() == name);
396                if !offline && implementation.is_none() {
397                    return Err(format!("task memory binding does not implement '{name}'"));
398                }
399                let effect = if name == "manage_memory" {
400                    TaskToolEffect::Commit {
401                        idempotency_argument: "request_id".into(),
402                    }
403                } else {
404                    TaskToolEffect::Read
405                };
406                skill.tools.push(SkillToolSpec {
407                    tool: ToolSpec {
408                        name: name.into(),
409                        description: implementation.map_or_else(
410                            || format!("Controlled memory {name}"),
411                            |tool| tool.description().to_owned(),
412                        ),
413                        parameters: implementation
414                            .and_then(gemini_adk_rs::ToolFunction::parameters),
415                        response: Some(json!({"fixture":true})),
416                        returns: None,
417                        set_state: BTreeMap::new(),
418                        save_response_as: None,
419                        http: None,
420                        mcp: None,
421                        background: false,
422                        scheduling: None,
423                    },
424                    effect,
425                });
426            }
427        }
428        for declaration in &mut skill.tools {
429            declaration.tool.parameters = task_parameters(declaration)?;
430        }
431        let tools = skill
432            .tools
433            .iter()
434            .map(|t| TaskTool {
435                name: t.tool.name.clone(),
436                description: t.tool.description.clone(),
437                parameters: t.tool.parameters.clone(),
438                effect: t.effect.clone(),
439            })
440            .collect();
441        let mut code: BTreeMap<String, Arc<dyn ToolFunction>> = if offline {
442            BTreeMap::new()
443        } else {
444            self.tools
445                .iter()
446                .filter_map(|tool| {
447                    resources
448                        .tools
449                        .get(&format!("{}/{}", self.name, tool.tool.name))
450                        .or_else(|| resources.tools.get(&tool.tool.name))
451                        .map(|implementation| (tool.tool.name.clone(), implementation.clone()))
452                })
453                .collect()
454        };
455        for tool in memory_tools {
456            code.insert(tool.name().to_owned(), tool);
457        }
458        Ok(CompiledSkill {
459            name: self.name.clone(),
460            version: self.version.clone(),
461            description: self.description.clone(),
462            instruction: self.instruction.clone(),
463            input_schema: field_schema(&self.inputs),
464            output_schema: field_schema(&self.outputs),
465            validate_input: Arc::new(move |value| validate_fields(&input_fields, value)),
466            validate_output: Arc::new(move |value| validate_fields(&output_fields, value)),
467            initial_state,
468            flow_factory,
469            services,
470            tool_factory: Arc::new(move |state| skill.bind_tools(state, &code, offline)),
471            tools,
472            project_output: Some(Arc::new(move |state| {
473                let value = Value::Object(
474                    projected_fields
475                        .keys()
476                        .filter_map(|key| state.get_raw(key).map(|value| (key.clone(), value)))
477                        .collect(),
478                );
479                validate_fields(&projected_fields, &value)?;
480                Ok(value)
481            })),
482            receipt_applier: Some(Arc::new(move |name, result, state| {
483                if memory_enabled && super::MEMORY_TOOL_NAMES.contains(&name) {
484                    return Ok(());
485                }
486                let declaration = receipt_tools
487                    .get(name)
488                    .ok_or_else(|| format!("unknown receipt tool '{name}'"))?;
489                apply_result_state(declaration, result, state)
490            })),
491            code: definition.compile_code_mode(resources.code_sandbox.clone()),
492        })
493    }
494
495    fn compile_services(
496        &self,
497        resources: &SpecResources,
498        offline: bool,
499        models: &super::ModelsSpec,
500    ) -> Result<Option<TaskServicesFactory>, String> {
501        if self.extract.is_empty()
502            && self.computed.is_empty()
503            && self.watch.is_empty()
504            && self.patterns.is_empty()
505            && self.memory.is_none()
506        {
507            return Ok(None);
508        }
509        // Each extract entry's model, resolved once.
510        let llms: Vec<Arc<dyn gemini_adk_rs::llm::BaseLlm>> = if offline {
511            // Controlled replay substitutes provider outputs after normal due selection.
512            // This empty script makes any accidental provider call fail.
513            let mock: Arc<dyn gemini_adk_rs::llm::BaseLlm> =
514                Arc::new(gemini_adk_rs::llm::MockLlm::script([]));
515            self.extract.iter().map(|_| mock.clone()).collect()
516        } else {
517            let mut cache = std::collections::BTreeMap::new();
518            self.extract
519                .iter()
520                .map(|e| {
521                    super::extraction_llm_for(
522                        e,
523                        models.extraction.as_deref(),
524                        resources,
525                        &mut cache,
526                    )
527                    .map_err(|err| format!("task {err}"))
528                })
529                .collect::<Result<_, _>>()?
530        };
531        let memory = self
532            .memory
533            .as_ref()
534            .map(|memory| {
535                let binding = resources
536                    .memory
537                    .as_ref()
538                    .ok_or("task memory requires SpecResources.memory")?;
539                let provider = binding.task_memory(memory)?;
540                let fields = self.definition().state;
541                Ok::<_, String>(Arc::new(ValidatedTaskMemory {
542                    provider,
543                    slots: memory.slots.clone(),
544                    fields,
545                })
546                    as Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>)
547            })
548            .transpose()?;
549        let skill = self.clone();
550        let models = models.clone();
551        let factory: TaskServicesFactory = Arc::new(move || {
552            let mut services = TaskServices {
553                memory: memory.clone(),
554                ..Default::default()
555            };
556            services.extractors = skill
557                .extract
558                .iter()
559                .zip(&llms)
560                .map(|(e, llm)| {
561                    Arc::new(super::compile_extractor(e, llm.clone(), &models))
562                        as Arc<dyn gemini_adk_rs::live::extractor::TurnExtractor>
563                })
564                .collect();
565            for spec in &skill.computed {
566                let expression = spec.from.clone();
567                services
568                    .computed
569                    .register(gemini_adk_rs::live::computed::ComputedVar {
570                        key: spec.key.clone(),
571                        dependencies: expression.keys_read().into_iter().collect(),
572                        compute: Arc::new(move |state| expression.eval(state)),
573                    })
574                    .map_err(|error| error.to_string())?;
575            }
576            services.watchers = skill
577                .watch
578                .iter()
579                .map(|watch| TaskWatcher {
580                    key: watch.key.clone(),
581                    predicate: match &watch.condition {
582                        super::WatchCondition::Changed => {
583                            gemini_adk_rs::live::watcher::WatchPredicate::Changed
584                        }
585                        super::WatchCondition::ChangedTo(v) => {
586                            gemini_adk_rs::live::watcher::WatchPredicate::ChangedTo(v.clone())
587                        }
588                        super::WatchCondition::CrossedAbove(v) => {
589                            gemini_adk_rs::live::watcher::WatchPredicate::CrossedAbove(*v)
590                        }
591                        super::WatchCondition::CrossedBelow(v) => {
592                            gemini_adk_rs::live::watcher::WatchPredicate::CrossedBelow(*v)
593                        }
594                        super::WatchCondition::BecameTrue => {
595                            gemini_adk_rs::live::watcher::WatchPredicate::BecameTrue
596                        }
597                        super::WatchCondition::BecameFalse => {
598                            gemini_adk_rs::live::watcher::WatchPredicate::BecameFalse
599                        }
600                    },
601                    effects: task_effects(&watch.set, &watch.effects),
602                })
603                .collect();
604            for pattern in &skill.patterns {
605                let guard = pattern.when.clone();
606                let condition = Arc::new(move |state: &State| guard.eval_state(state));
607                let detector: Box<dyn gemini_adk_rs::live::temporal::PatternDetector> =
608                    match (pattern.sustained_secs, pattern.turns) {
609                        (Some(seconds), None) => {
610                            Box::new(gemini_adk_rs::live::temporal::SustainedDetector::new(
611                                condition,
612                                std::time::Duration::from_secs(seconds),
613                            ))
614                        }
615                        (None, Some(turns)) => Box::new(
616                            gemini_adk_rs::live::temporal::TurnCountDetector::new(condition, turns),
617                        ),
618                        _ => {
619                            return Err(format!(
620                                "pattern '{}' requires exactly one duration or turn count",
621                                pattern.name
622                            ));
623                        }
624                    };
625                services.patterns.push(TaskPattern {
626                    name: pattern.name.clone(),
627                    detector,
628                    cooldown: None,
629                    effects: task_effects(&BTreeMap::new(), &pattern.effects),
630                });
631            }
632            Ok(services)
633        });
634        // Validate the fresh registries now; errors must not wait until Start.
635        factory()?;
636        Ok(Some(factory))
637    }
638
639    fn bind_tools(
640        &self,
641        state: State,
642        code: &BTreeMap<String, Arc<dyn ToolFunction>>,
643        offline: bool,
644    ) -> Result<ToolDispatcher, String> {
645        let mut dispatcher = ToolDispatcher::new();
646        for declaration in &self.tools {
647            let tool = declaration.tool.clone();
648            let implementation = code.get(&tool.name).cloned();
649            let server = if offline {
650                None
651            } else {
652                tool.mcp.as_ref().map(|params| {
653                    Arc::new(gemini_adk_rs::tools::mcp::McpSessionManager::new(
654                        crate::live::connect::parse_mcp_params(params),
655                    ))
656                })
657            };
658            let snapshot = state.clone();
659            dispatcher.register(ContextTool::new(
660                tool.name.clone(),
661                tool.description.clone(),
662                tool.parameters.clone(),
663                move |args, mut ctx: ToolContext| {
664                    let tool = tool.clone();
665                    let implementation = implementation.clone();
666                    let server = server.clone();
667                    let snapshot = snapshot.clone();
668                    async move {
669                        ctx.state = snapshot.clone();
670                        let result = if let Some(implementation) = implementation {
671                            implementation.call_with_context(args, ctx).await?
672                        } else if let Some(server) = server {
673                            super::mcp_value(
674                                server
675                                    .call_tool(&tool.name, args)
676                                    .await
677                                    .map_err(|e| gemini_adk_rs::ToolError::Other(e.to_string()))?,
678                            )
679                        } else if let Some(binding) = tool.http.as_ref().filter(|_| !offline) {
680                            super::execute_http(binding, &args, &snapshot)
681                                .await
682                                .map_err(gemini_adk_rs::ToolError::Other)?
683                        } else {
684                            tool.response.clone().unwrap_or_else(|| json!({"ok":true}))
685                        };
686                        apply_result_state(&tool, &result, &snapshot)
687                            .map_err(gemini_adk_rs::ToolError::Other)?;
688                        Ok(result)
689                    }
690                },
691            ));
692        }
693        if let Some(conversation) = &self.conversation {
694            let mut dispatcher = Some(dispatcher);
695            crate::live::connect::apply_policies(&conversation.policies, &state, &mut dispatcher)
696                .map_err(|e| e.to_string())?;
697            Ok(dispatcher.expect("policy application retains dispatcher"))
698        } else {
699            Ok(dispatcher)
700        }
701    }
702
703    pub(super) fn sandboxed(&self, allow: &super::BindingAllowlist) -> (Self, Vec<String>) {
704        let (definition, notes) = self.definition().sandboxed(allow);
705        let mut skill = self.clone();
706        for (target, source) in skill.tools.iter_mut().zip(definition.tools) {
707            target.tool = source;
708        }
709        (
710            skill,
711            notes
712                .into_iter()
713                .map(|note| format!("skill '{}': {note}", self.name))
714                .collect(),
715        )
716    }
717}
718
719/// Apply only the declared state effects of an accepted success. Trusted
720/// reconciliation uses this same adapter without executing the external tool.
721fn apply_result_state(tool: &ToolSpec, result: &Value, state: &State) -> Result<(), String> {
722    for (key, value) in &tool.set_state {
723        state.set(key, value.clone()).map_err(|e| e.to_string())?;
724    }
725    if let Some(key) = &tool.save_response_as {
726        state.set(key, result.clone()).map_err(|e| e.to_string())?;
727    }
728    Ok(())
729}
730
731pub(super) fn task_parameters(declaration: &SkillToolSpec) -> Result<Option<Value>, String> {
732    let TaskToolEffect::Commit {
733        idempotency_argument,
734    } = &declaration.effect
735    else {
736        return Ok(declaration.tool.parameters.clone());
737    };
738    let invalid = || {
739        format!(
740            "commit tool '{}' needs object parameters with string idempotency field '{idempotency_argument}'",
741            declaration.tool.name
742        )
743    };
744    let mut parameters = declaration
745        .tool
746        .parameters
747        .clone()
748        .unwrap_or_else(|| json!({}));
749    let object = parameters.as_object_mut().ok_or_else(invalid)?;
750    if object.get("type").is_some_and(|kind| kind != "object") {
751        return Err(invalid());
752    }
753    object.insert("type".into(), json!("object"));
754    let properties = object
755        .entry("properties")
756        .or_insert_with(|| json!({}))
757        .as_object_mut()
758        .ok_or_else(invalid)?;
759    let field = properties
760        .entry(idempotency_argument.clone())
761        .or_insert_with(|| json!({}))
762        .as_object_mut()
763        .ok_or_else(invalid)?;
764    if field.get("type").is_some_and(|kind| kind != "string") {
765        return Err(invalid());
766    }
767    field.insert("type".into(), json!("string"));
768    field
769        .entry("description")
770        .or_insert_with(|| json!("Nonempty external idempotency key for this exact operation."));
771    let required = object
772        .entry("required")
773        .or_insert_with(|| json!([]))
774        .as_array_mut()
775        .ok_or_else(invalid)?;
776    if !required.iter().all(Value::is_string) {
777        return Err(invalid());
778    }
779    if !required.iter().any(|key| key == idempotency_argument) {
780        required.push(json!(idempotency_argument));
781    }
782    Ok(Some(parameters))
783}
784
785fn field_schema(fields: &BTreeMap<String, StateFieldSpec>) -> Value {
786    let properties: serde_json::Map<String, Value> = fields
787        .iter()
788        .map(|(name, field)| {
789            let mut schema = json!({"description": field.description});
790            if let Some(kind) = field.kind {
791                schema["type"] = json!(match kind {
792                    StateType::Boolean => "boolean",
793                    StateType::Number => "number",
794                    StateType::String => "string",
795                    StateType::Object => "object",
796                    StateType::Array => "array",
797                });
798            }
799            if let Some(default) = &field.default {
800                schema["default"] = default.clone();
801            }
802            (name.clone(), schema)
803        })
804        .collect();
805    json!({"type":"object", "properties":properties, "required":fields.iter().filter(|(_, f)| f.default.is_none()).map(|(k, _)| k).collect::<Vec<_>>(), "additionalProperties":false})
806}
807
808fn validate_fields(fields: &BTreeMap<String, StateFieldSpec>, value: &Value) -> Result<(), String> {
809    let object = value.as_object().ok_or("contract expects an object")?;
810    for key in object.keys() {
811        if !fields.contains_key(key) {
812            return Err(format!("unknown contract field '{key}'"));
813        }
814    }
815    for (key, field) in fields {
816        match object.get(key) {
817            Some(value) if field.kind.is_some_and(|kind| !kind.matches(value)) => {
818                return Err(format!("field '{key}' has the wrong type"));
819            }
820            None if field.default.is_none() => {
821                return Err(format!("required field '{key}' is missing"));
822            }
823            _ => {}
824        }
825    }
826    Ok(())
827}
828
829fn task_effects(sets: &BTreeMap<String, Value>, effects: &[super::EffectSpec]) -> TaskEffects {
830    let sets = sets.clone();
831    let effects = effects.to_vec();
832    Arc::new(move |state| {
833        let mut out: Vec<_> = sets
834            .iter()
835            .map(|(key, value)| TaskEffect::Set {
836                key: key.clone(),
837                value: value.clone(),
838            })
839            .collect();
840        for effect in &effects {
841            match effect {
842                super::EffectSpec::Set(values) => {
843                    out.extend(values.iter().map(|(key, value)| TaskEffect::Set {
844                        key: key.clone(),
845                        value: value.clone(),
846                    }));
847                }
848                super::EffectSpec::Context(text) => out.push(TaskEffect::Context(text.clone())),
849                super::EffectSpec::Prompt(text) => out.push(TaskEffect::Prompt(text.clone())),
850                super::EffectSpec::Remember(template) => out.push(TaskEffect::Remember(
851                    super::interpolate(template, &Value::Null, state),
852                )),
853            }
854        }
855        out
856    })
857}
858
859struct ValidatedTaskMemory {
860    provider: Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>,
861    slots: Vec<super::MemorySlotSpec>,
862    fields: BTreeMap<String, StateFieldSpec>,
863}
864impl ValidatedTaskMemory {
865    fn validate_projection(&self, value: Value) -> Result<Value, String> {
866        let object = value
867            .as_object()
868            .ok_or("task memory projection must be an object")?;
869        for (key, value) in object {
870            if !self.slots.iter().any(|slot| slot.to == *key) {
871                return Err(format!("task memory projected undeclared slot '{key}'"));
872            }
873            if !value.is_null()
874                && self
875                    .fields
876                    .get(key)
877                    .and_then(|field| field.kind)
878                    .is_some_and(|kind| !kind.matches(value))
879            {
880                return Err(format!("task memory slot '{key}' has the wrong type"));
881            }
882        }
883        Ok(value)
884    }
885}
886#[async_trait::async_trait]
887impl gemini_adk_rs::tasks::TaskMemoryService for ValidatedTaskMemory {
888    async fn project(&self) -> Result<Value, String> {
889        self.validate_projection(self.provider.project().await?)
890    }
891    async fn ingest(&self, id: String, turn: u64, user: String) -> Result<Value, String> {
892        self.validate_projection(self.provider.ingest(id, turn, user).await?)
893    }
894    async fn remember(&self, id: String, turn: u64, note: String) -> Result<Value, String> {
895        self.validate_projection(self.provider.remember(id, turn, note).await?)
896    }
897}
898
899/// A deterministic journey through task commands and controlled tool effects.
900#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
901pub struct TaskScenario {
902    /// Display name used in CI and Studio.
903    pub name: String,
904    /// Events in order; task and operation IDs match the runtime snapshots.
905    pub steps: Vec<TaskStep>,
906}
907
908/// One event applied by both scripted task tests and Studio Preview.
909#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
910#[serde(tag = "event", rename_all = "snake_case")]
911pub enum TaskStep {
912    /// Apply a trusted command. Tools run with declared mock implementations.
913    Command {
914        /// The same command accepted by the live runtime.
915        command: gemini_adk_rs::tasks::TaskCommand,
916        /// Hold an admitted invocation until an explicit finish event.
917        #[serde(default)]
918        defer: bool,
919        /// An anticipated admission error; no error means the step fails.
920        #[serde(default, skip_serializing_if = "Option::is_none")]
921        expected_error: Option<String>,
922        /// Controlled results for due after-tool extractors.
923        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
924        extraction: BTreeMap<String, Value>,
925        /// Controlled extraction provider failures.
926        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
927        extraction_errors: BTreeMap<String, String>,
928    },
929    /// Advance the same task-local governance as a live turn boundary.
930    Turn,
931    /// Execute and finish one previously deferred controlled invocation.
932    Finish {
933        /// Invocation identity from the runtime's operation snapshot.
934        operation: gemini_adk_rs::tasks::OperationId,
935        /// Controlled results for due after-tool extractors.
936        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
937        extraction: BTreeMap<String, Value>,
938        /// Controlled extraction provider failures.
939        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
940        extraction_errors: BTreeMap<String, String>,
941    },
942    /// Observe owned speech through the same trigger/window/promotion pipeline.
943    Observe {
944        /// Activation receiving the observation.
945        task: gemini_adk_rs::tasks::TaskId,
946        /// Captured revision; omitted means the current revision at this step.
947        #[serde(default, skip_serializing_if = "Option::is_none")]
948        revision: Option<u64>,
949        /// Observation kind, defaulting to finalized turn.
950        #[serde(default)]
951        trigger: TaskObservationKind,
952        /// Finalized user text.
953        #[serde(default)]
954        user: String,
955        /// Heard or generated model text, depending on trigger.
956        #[serde(default)]
957        model: String,
958        /// Explicit results from the controlled extractor provider.
959        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
960        extraction: BTreeMap<String, Value>,
961        /// Explicit provider failures, keyed by extractor name.
962        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
963        extraction_errors: BTreeMap<String, String>,
964        /// Hold prepared work until finish_service, allowing stale-result tests.
965        #[serde(default)]
966        defer: bool,
967    },
968    /// Complete a previously deferred service ticket.
969    FinishService {
970        /// Service operation identity from its owned ticket.
971        operation: gemini_adk_rs::tasks::OperationId,
972    },
973    /// Advance the shared manual clock and observe a foreground timer tick.
974    AdvanceTime {
975        /// Elapsed milliseconds.
976        millis: u64,
977    },
978    /// Seed the offline memory adapter, never authoritative task state.
979    Memory {
980        /// Skill whose controlled memory backend receives these fixtures.
981        skill: String,
982        /// Projection values keyed by declared slot target.
983        values: BTreeMap<String, Value>,
984    },
985    /// Assert observable task state without exporting private task state.
986    Expect {
987        /// Foreground task ID, when it must be set to this value.
988        #[serde(default, skip_serializing_if = "Option::is_none")]
989        foreground: Option<gemini_adk_rs::tasks::TaskId>,
990        /// Task observations that must hold.
991        #[serde(default, skip_serializing_if = "Vec::is_empty")]
992        tasks: Vec<TaskExpectation>,
993        /// Operation outcomes that must hold.
994        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
995        operations: BTreeMap<String, gemini_adk_rs::tasks::OperationStatus>,
996    },
997}
998
999/// Serializable owned-observation vocabulary for task replay.
1000#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
1001#[serde(rename_all = "snake_case")]
1002pub enum TaskObservationKind {
1003    /// Finalized user/heard-model turn.
1004    #[default]
1005    Turn,
1006    /// Generated model text before interruption truncation.
1007    GenerationComplete,
1008    /// Owned interruption.
1009    Interrupted,
1010    /// Owned timer tick.
1011    Timer,
1012}
1013
1014/// An assertion about one task activation.
1015#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
1016pub struct TaskExpectation {
1017    /// Task ID to inspect.
1018    pub task: String,
1019    /// Expected activation lifecycle.
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub status: Option<gemini_adk_rs::tasks::TaskStatus>,
1022    /// Expected pinned skill version.
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub version: Option<String>,
1025    /// Expected input revision.
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub revision: Option<u64>,
1028    /// Expected validated export.
1029    #[serde(default, skip_serializing_if = "Option::is_none")]
1030    pub output: Option<Value>,
1031    /// Active steps in the task's current governance layer.
1032    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1033    pub active: Vec<String>,
1034    /// Completed steps in the task's current governance layer.
1035    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1036    pub done: Vec<String>,
1037}
1038
1039/// One shared runtime snapshot after an offline task event.
1040#[derive(Debug, Clone, Serialize)]
1041pub struct TaskTraceSnapshot {
1042    /// Zero is the initial catalog before any command.
1043    pub index: usize,
1044    /// Human-readable event label.
1045    pub event: String,
1046    /// Failed assertions or unexpected command errors.
1047    pub failures: Vec<String>,
1048    /// Owner-labelled effects accepted by task services during this event.
1049    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1050    pub service_effects: Vec<gemini_adk_rs::tasks::AcceptedTaskEffect>,
1051    /// Deferred service identities, allowing an explicit finish_service event.
1052    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1053    pub deferred_services: Vec<gemini_adk_rs::tasks::TaskOwner>,
1054    /// The exact task observation forwarded by live sessions.
1055    #[serde(flatten)]
1056    pub status: gemini_adk_rs::tasks::TaskSessionSnapshot,
1057}
1058
1059impl SessionSpec {
1060    /// Compile local skill definitions into the runtime used by Live.
1061    pub fn compile_tasks(
1062        &self,
1063        resources: &SpecResources,
1064    ) -> Result<gemini_adk_rs::tasks::TaskRuntime, String> {
1065        self.compile_tasks_mode(resources, false)
1066    }
1067
1068    fn compile_tasks_mode(
1069        &self,
1070        resources: &SpecResources,
1071        offline: bool,
1072    ) -> Result<gemini_adk_rs::tasks::TaskRuntime, String> {
1073        let validation = if offline {
1074            self.validate_for_replay()
1075        } else {
1076            self.validate()
1077        };
1078        if !validation.valid {
1079            return Err(validation.errors.join("; "));
1080        }
1081        if self.skills.is_empty() {
1082            return Err("the session has no skills".into());
1083        }
1084        let skills = self
1085            .skills
1086            .iter()
1087            .map(|skill| skill.compile_mode(resources, offline, &self.models))
1088            .collect::<Result<Vec<_>, _>>()?;
1089        gemini_adk_rs::tasks::TaskRuntime::new(skills).map_err(|e| e.to_string())
1090    }
1091
1092    /// Replay commands with declared mock tools only. Attached implementations,
1093    /// HTTP bindings and MCP servers never execute in this path.
1094    pub async fn trace_tasks(
1095        &self,
1096        steps: &[TaskStep],
1097    ) -> Result<
1098        (
1099            Vec<TaskTraceSnapshot>,
1100            gemini_adk_rs::tasks::TaskSessionSnapshot,
1101        ),
1102        Vec<String>,
1103    > {
1104        self.trace_tasks_with_resources(steps, &SpecResources::default())
1105            .await
1106    }
1107
1108    /// Replay with explicitly supplied controlled memory services. Tool implementations
1109    /// and extraction providers remain disabled; a supplied memory binding executes
1110    /// its project/ingest/remember methods and must be an operator-controlled fixture.
1111    pub async fn trace_tasks_with_resources(
1112        &self,
1113        steps: &[TaskStep],
1114        resources: &SpecResources,
1115    ) -> Result<
1116        (
1117            Vec<TaskTraceSnapshot>,
1118            gemini_adk_rs::tasks::TaskSessionSnapshot,
1119        ),
1120        Vec<String>,
1121    > {
1122        let validation = self.validate_for_replay();
1123        if !validation.valid {
1124            return Err(validation.errors);
1125        }
1126        if self.skills.is_empty() {
1127            return Err(vec!["the session has no skills".into()]);
1128        }
1129        let mut memory_fixtures = BTreeMap::new();
1130        let mut compiled = Vec::new();
1131        for skill in &self.skills {
1132            let mut bindings = resources.clone();
1133            if skill.memory.is_some() && bindings.memory.is_none() {
1134                let fixture = Arc::new(FixtureMemory::default());
1135                bindings.memory = Some(Arc::new(FixtureMemoryBinding(fixture.clone())));
1136                memory_fixtures.insert(skill.name.clone(), fixture);
1137            }
1138            compiled.push(
1139                skill
1140                    .compile_mode(&bindings, true, &super::ModelsSpec::default())
1141                    .map_err(|error| vec![error])?,
1142            );
1143        }
1144        let clock = Arc::new(gemini_adk_rs::clock::ManualClock::new());
1145        let mut runtime = gemini_adk_rs::tasks::TaskRuntime::new(compiled)
1146            .map_err(|error| vec![error.to_string()])?
1147            .with_clock(clock.clone());
1148        let mut pending = BTreeMap::new();
1149        let mut pending_services: DeferredServices = BTreeMap::new();
1150        let mut snapshots = vec![TaskTraceSnapshot {
1151            index: 0,
1152            event: "initial".into(),
1153            failures: Vec::new(),
1154            service_effects: Vec::new(),
1155            deferred_services: Vec::new(),
1156            status: runtime.snapshot(),
1157        }];
1158        for (index, step) in steps.iter().enumerate() {
1159            let mut failures = Vec::new();
1160            let mut controlled = BTreeMap::new();
1161            let mut defer_services = false;
1162            let event = match step {
1163                TaskStep::Command {
1164                    command,
1165                    defer,
1166                    expected_error,
1167                    extraction,
1168                    extraction_errors,
1169                } => {
1170                    controlled = controlled_results(extraction, extraction_errors, &mut failures);
1171                    match runtime.command(command.clone()) {
1172                        Ok(invocation) => {
1173                            if let Some(expected) = expected_error {
1174                                failures.push(format!(
1175                                    "expected command error '{expected}', command succeeded"
1176                                ));
1177                            }
1178                            if let Some(invocation) = invocation {
1179                                if *defer {
1180                                    pending.insert(invocation.operation_id().clone(), invocation);
1181                                } else {
1182                                    runtime.complete(invocation.execute().await);
1183                                }
1184                            }
1185                        }
1186                        Err(error) => {
1187                            let message = error.to_string();
1188                            if !expected_error
1189                                .as_ref()
1190                                .is_some_and(|expected| message.contains(expected))
1191                            {
1192                                failures.push(message);
1193                            }
1194                        }
1195                    }
1196                    serde_json::to_value(command)
1197                        .ok()
1198                        .and_then(|v| v["action"].as_str().map(str::to_owned))
1199                        .unwrap_or_else(|| "command".into())
1200                }
1201                TaskStep::Turn => {
1202                    runtime.on_turn();
1203                    "turn".into()
1204                }
1205                TaskStep::Finish {
1206                    operation,
1207                    extraction,
1208                    extraction_errors,
1209                } => {
1210                    controlled = controlled_results(extraction, extraction_errors, &mut failures);
1211                    match pending.remove(operation) {
1212                        Some(invocation) => {
1213                            runtime.complete(invocation.execute().await);
1214                        }
1215                        None => failures.push(format!("no deferred invocation '{}'", operation.0)),
1216                    }
1217                    format!("finish: {}", operation.0)
1218                }
1219                TaskStep::Observe {
1220                    task,
1221                    revision,
1222                    trigger,
1223                    user,
1224                    model,
1225                    extraction,
1226                    extraction_errors,
1227                    defer,
1228                } => {
1229                    controlled = controlled_results(extraction, extraction_errors, &mut failures);
1230                    defer_services = *defer;
1231                    let revision = revision.or_else(|| {
1232                        runtime
1233                            .snapshot()
1234                            .tasks
1235                            .iter()
1236                            .find(|activation| activation.id == *task)
1237                            .map(|activation| activation.revision)
1238                    });
1239                    let event = match trigger {
1240                        TaskObservationKind::Turn => gemini_adk_rs::tasks::TaskObservation::Turn {
1241                            user: user.clone(),
1242                            model: model.clone(),
1243                        },
1244                        TaskObservationKind::GenerationComplete => {
1245                            gemini_adk_rs::tasks::TaskObservation::GenerationComplete {
1246                                user: user.clone(),
1247                                model: model.clone(),
1248                            }
1249                        }
1250                        TaskObservationKind::Interrupted => {
1251                            gemini_adk_rs::tasks::TaskObservation::Interrupted
1252                        }
1253                        TaskObservationKind::Timer => gemini_adk_rs::tasks::TaskObservation::Timer,
1254                    };
1255                    if let Some(revision) = revision {
1256                        if let Err(error) = runtime.observe(task, revision, event) {
1257                            failures.push(error.to_string());
1258                        }
1259                    } else {
1260                        failures.push(format!("task '{}' does not exist", task.0));
1261                    }
1262                    format!("observe: {}", task.0)
1263                }
1264                TaskStep::FinishService { operation } => {
1265                    match pending_services.remove(operation) {
1266                        Some((work, fixtures)) => {
1267                            runtime.complete_services(work.execute_controlled(fixtures).await);
1268                        }
1269                        None => failures.push(format!("no deferred service '{}'", operation.0)),
1270                    }
1271                    format!("finish_service: {}", operation.0)
1272                }
1273                TaskStep::AdvanceTime { millis } => {
1274                    clock.advance(std::time::Duration::from_millis(*millis));
1275                    let snapshot = runtime.snapshot();
1276                    if let Some(task) = snapshot
1277                        .tasks
1278                        .iter()
1279                        .find(|task| snapshot.foreground.as_ref() == Some(&task.id))
1280                        && let Err(error) = runtime.observe(
1281                            &task.id,
1282                            task.revision,
1283                            gemini_adk_rs::tasks::TaskObservation::Timer,
1284                        )
1285                    {
1286                        failures.push(error.to_string());
1287                    }
1288                    format!("advance_time: {millis}")
1289                }
1290                TaskStep::Memory { skill, values } => {
1291                    if let Some(fixture) = memory_fixtures.get(skill) {
1292                        let declaration = self
1293                            .skills
1294                            .iter()
1295                            .find(|candidate| candidate.name == *skill)
1296                            .and_then(|skill| skill.memory.as_ref());
1297                        if values.keys().any(|key| {
1298                            !declaration.is_some_and(|memory| {
1299                                memory.slots.iter().any(|slot| slot.to == *key)
1300                            })
1301                        }) {
1302                            failures.push(format!(
1303                                "memory fixture for '{skill}' contains an undeclared slot"
1304                            ));
1305                        } else {
1306                            *fixture.values.write().expect("fixture memory lock") = json!(values);
1307                        }
1308                    } else {
1309                        failures.push(format!("'{skill}' has no controlled replay memory adapter"));
1310                    }
1311                    format!("memory fixture: {skill}")
1312                }
1313                TaskStep::Expect {
1314                    foreground,
1315                    tasks,
1316                    operations,
1317                } => {
1318                    let snapshot = runtime.snapshot();
1319                    if let Some(expected) = foreground
1320                        && snapshot.foreground.as_ref() != Some(expected)
1321                    {
1322                        failures.push(format!(
1323                            "expected foreground '{}'; got {:?}",
1324                            expected.0, snapshot.foreground
1325                        ));
1326                    }
1327                    for expected in tasks {
1328                        let Some(task) = snapshot
1329                            .tasks
1330                            .iter()
1331                            .find(|task| task.id.0 == expected.task)
1332                        else {
1333                            failures.push(format!("task '{}' does not exist", expected.task));
1334                            continue;
1335                        };
1336                        if expected.status.is_some_and(|status| status != task.status) {
1337                            failures.push(format!(
1338                                "task '{}' status was {:?}",
1339                                expected.task, task.status
1340                            ));
1341                        }
1342                        if expected
1343                            .version
1344                            .as_ref()
1345                            .is_some_and(|version| version != &task.skill.version)
1346                        {
1347                            failures.push(format!(
1348                                "task '{}' version was '{}'",
1349                                expected.task, task.skill.version
1350                            ));
1351                        }
1352                        if expected
1353                            .revision
1354                            .is_some_and(|revision| revision != task.revision)
1355                        {
1356                            failures.push(format!(
1357                                "task '{}' revision was {}",
1358                                expected.task, task.revision
1359                            ));
1360                        }
1361                        if expected
1362                            .output
1363                            .as_ref()
1364                            .is_some_and(|output| Some(output) != task.output.as_ref())
1365                        {
1366                            failures.push(format!(
1367                                "task '{}' output was {:?}",
1368                                expected.task, task.output
1369                            ));
1370                        }
1371                        for step in &expected.active {
1372                            if !task
1373                                .flow
1374                                .as_ref()
1375                                .is_some_and(|flow| flow.explanation.active.contains(step))
1376                            {
1377                                failures.push(format!(
1378                                    "task '{}' expected active step '{step}'",
1379                                    expected.task
1380                                ));
1381                            }
1382                        }
1383                        for step in &expected.done {
1384                            if !task
1385                                .flow
1386                                .as_ref()
1387                                .is_some_and(|flow| flow.done.contains(step))
1388                            {
1389                                failures.push(format!(
1390                                    "task '{}' expected done step '{step}'",
1391                                    expected.task
1392                                ));
1393                            }
1394                        }
1395                    }
1396                    for (id, expected) in operations {
1397                        if !snapshot
1398                            .operations
1399                            .iter()
1400                            .any(|operation| operation.id.0 == *id && operation.status == *expected)
1401                        {
1402                            failures.push(format!("operation '{id}' expected {expected:?}"));
1403                        }
1404                    }
1405                    "expect".into()
1406                }
1407            };
1408            drain_services(
1409                &mut runtime,
1410                &mut controlled,
1411                defer_services,
1412                &mut pending_services,
1413            )
1414            .await;
1415            for name in controlled.keys() {
1416                failures.push(format!("unused controlled extraction '{name}'"));
1417            }
1418            let service_effects = runtime.take_service_effects();
1419            for effect in &service_effects {
1420                if let TaskEffect::Error(error) = &effect.effect {
1421                    failures.push(error.clone());
1422                }
1423            }
1424            snapshots.push(TaskTraceSnapshot {
1425                index: index + 1,
1426                event,
1427                failures,
1428                service_effects,
1429                deferred_services: pending_services
1430                    .values()
1431                    .map(|(work, _)| work.owner().clone())
1432                    .collect(),
1433                status: runtime.snapshot(),
1434            });
1435        }
1436        Ok((snapshots, runtime.snapshot()))
1437    }
1438
1439    /// Run every declared task journey through the shared offline runtime.
1440    pub async fn run_task_scenarios(&self) -> Vec<super::ScenarioReport> {
1441        let mut reports = Vec::new();
1442        for scenario in &self.task_scenarios {
1443            let error = match self.trace_tasks(&scenario.steps).await {
1444                Ok((snapshots, _)) => snapshots
1445                    .into_iter()
1446                    .find(|snapshot| !snapshot.failures.is_empty())
1447                    .map(|snapshot| {
1448                        format!("event {}: {}", snapshot.index, snapshot.failures.join("; "))
1449                    }),
1450                Err(errors) => Some(errors.join("; ")),
1451            };
1452            reports.push(super::ScenarioReport {
1453                name: scenario.name.clone(),
1454                passed: error.is_none(),
1455                error,
1456            });
1457        }
1458        reports
1459    }
1460}
1461
1462type ControlledExtraction = BTreeMap<String, Result<Value, String>>;
1463type DeferredServices = BTreeMap<
1464    gemini_adk_rs::tasks::OperationId,
1465    (
1466        gemini_adk_rs::tasks::OwnedTaskServiceWork,
1467        ControlledExtraction,
1468    ),
1469>;
1470fn controlled_results(
1471    values: &BTreeMap<String, Value>,
1472    errors: &BTreeMap<String, String>,
1473    failures: &mut Vec<String>,
1474) -> ControlledExtraction {
1475    let mut results: ControlledExtraction = values
1476        .iter()
1477        .map(|(name, value)| (name.clone(), Ok(value.clone())))
1478        .collect();
1479    for (name, error) in errors {
1480        if results.insert(name.clone(), Err(error.clone())).is_some() {
1481            failures.push(format!(
1482                "extractor '{name}' has both a result and an error fixture"
1483            ));
1484        }
1485    }
1486    results
1487}
1488async fn drain_services(
1489    runtime: &mut gemini_adk_rs::tasks::TaskRuntime,
1490    fixtures: &mut ControlledExtraction,
1491    defer: bool,
1492    pending: &mut DeferredServices,
1493) {
1494    loop {
1495        let jobs = runtime.take_ready_services();
1496        if jobs.is_empty() {
1497            break;
1498        }
1499        for work in jobs {
1500            let selected = work
1501                .extractor_names()
1502                .into_iter()
1503                .filter_map(|name| fixtures.remove_entry(name))
1504                .collect();
1505            if defer {
1506                pending.insert(work.owner().operation.clone(), (work, selected));
1507            } else {
1508                runtime.complete_services(work.execute_controlled(selected).await);
1509            }
1510        }
1511    }
1512}
1513#[derive(Default)]
1514struct FixtureMemory {
1515    values: std::sync::RwLock<Value>,
1516}
1517impl FixtureMemory {
1518    fn projection(&self) -> Value {
1519        let value = self.values.read().expect("fixture memory lock").clone();
1520        if value.is_object() { value } else { json!({}) }
1521    }
1522}
1523#[async_trait::async_trait]
1524impl gemini_adk_rs::tasks::TaskMemoryService for FixtureMemory {
1525    async fn project(&self) -> Result<Value, String> {
1526        Ok(self.projection())
1527    }
1528    async fn ingest(&self, _id: String, _turn: u64, _user: String) -> Result<Value, String> {
1529        Ok(self.projection())
1530    }
1531    async fn remember(&self, _id: String, _turn: u64, _note: String) -> Result<Value, String> {
1532        Ok(self.projection())
1533    }
1534}
1535struct FixtureMemoryBinding(Arc<FixtureMemory>);
1536impl super::MemoryBinding for FixtureMemoryBinding {
1537    fn install(&self, live: crate::live::Live, _memory: &super::MemorySpec) -> crate::live::Live {
1538        live
1539    }
1540    fn remember(&self, _note: String) {}
1541    fn task_memory(
1542        &self,
1543        _memory: &super::MemorySpec,
1544    ) -> Result<Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>, String> {
1545        Ok(self.0.clone())
1546    }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551    use super::*;
1552    use gemini_adk_rs::tasks::{TaskCommand, TaskId};
1553
1554    fn simple() -> SessionSpec {
1555        SessionSpec::from_value(json!({
1556            "name":"support",
1557            "skills":[{"name":"faq","version":"1","instruction":"Answer.",
1558                "inputs":{"question":{"type":"string"}},
1559                "outputs":{"result":{"type":"object"}},
1560                "tools":[{"name":"lookup","effect":{"kind":"read"},
1561                    "response":{"answer":"help"},"save_response_as":"result"}]}]
1562        }))
1563        .expect("parses")
1564    }
1565
1566    #[test]
1567    fn effect_policies_and_skill_versions_are_explicit() {
1568        let mut spec = simple();
1569        spec.skills[0].version.clear();
1570        assert!(!spec.validate().valid);
1571        let mut value = serde_json::to_value(simple()).expect("serializes");
1572        value["skills"][0]["tools"][0]
1573            .as_object_mut()
1574            .expect("tool")
1575            .remove("effect");
1576        assert!(SessionSpec::from_value(value).is_err());
1577    }
1578
1579    #[test]
1580    fn authoring_and_runtime_share_provider_name_validation() {
1581        let mut spec = simple();
1582        spec.skills[0].name = "faq__nested".into();
1583        assert!(!spec.validate().valid);
1584        let mut spec = simple();
1585        spec.skills[0].tools[0].tool.name = "x".repeat(62);
1586        assert!(
1587            !spec.validate().valid,
1588            "qualified names are at most 64 bytes"
1589        );
1590        let mut spec = simple();
1591        spec.skills[0].tools[0].tool.background = true;
1592        assert!(
1593            spec.validate()
1594                .errors
1595                .iter()
1596                .any(|error| error.contains("delivery"))
1597        );
1598    }
1599
1600    #[test]
1601    fn required_activation_inputs_are_writers_but_unproduced_state_still_warns() {
1602        let mut spec = simple();
1603        spec.skills[0].state.insert(
1604            "missing".into(),
1605            serde_json::from_value(json!({"type":"string"})).expect("declared field"),
1606        );
1607        spec.skills[0].flow = Some(
1608            serde_json::from_value(json!({
1609                "steps":[
1610                    {"id":"collect","allow":["lookup"],"done":{"captured":["question","missing"]}},
1611                    {"id":"done","after":["collect"],"terminal":true}
1612                ],
1613                "constraints":[{"require":["done"]}]
1614            }))
1615            .expect("flow"),
1616        );
1617        let validation = spec.validate();
1618        assert!(validation.valid, "{:?}", validation.errors);
1619        assert!(
1620            !validation
1621                .warnings
1622                .iter()
1623                .any(|warning| warning.contains("guard reads state key 'question'")),
1624            "a required activation input is populated by the runtime"
1625        );
1626        assert!(
1627            validation
1628                .warnings
1629                .iter()
1630                .any(|warning| warning.contains("guard reads state key 'missing'")),
1631            "declaring a non-input state field does not create a producer"
1632        );
1633        assert!(
1634            spec.skills[0]
1635                .definition()
1636                .validate()
1637                .warnings
1638                .iter()
1639                .any(|warning| warning.contains("guard reads state key 'question'")),
1640            "ordinary session state retains the missing-producer warning"
1641        );
1642    }
1643
1644    #[test]
1645    fn commit_declarations_advertise_the_required_idempotency_argument() {
1646        let mut spec = simple();
1647        spec.skills[0].tools[0].effect = TaskToolEffect::Commit {
1648            idempotency_argument: "request_id".into(),
1649        };
1650        let compiled = spec.skills[0]
1651            .compile(&SpecResources::default())
1652            .expect("compiles");
1653        let parameters = compiled.tools[0]
1654            .parameters
1655            .as_ref()
1656            .expect("commit schema");
1657        assert_eq!(parameters["type"], "object");
1658        assert_eq!(parameters["properties"]["request_id"]["type"], "string");
1659        assert_eq!(parameters["required"], json!(["request_id"]));
1660        let dispatcher = (compiled.tool_factory)(State::new()).expect("binds");
1661        let gemini_adk_rs::tool::ToolKind::Function(tool) =
1662            dispatcher.get_tool("lookup").expect("tool")
1663        else {
1664            panic!("declared task tools are function tools");
1665        };
1666        assert_eq!(tool.parameters(), Some(parameters.clone()));
1667        spec.skills[0].tools[0].tool.parameters = Some(json!({
1668            "type":"object","properties":{"request_id":{"type":"number"}}
1669        }));
1670        assert!(!spec.validate().valid, "incompatible authored field fails");
1671    }
1672
1673    #[test]
1674    fn contracts_and_ambiguous_global_pipelines_fail_at_load() {
1675        let mut spec = simple();
1676        spec.skills[0]
1677            .inputs
1678            .get_mut("question")
1679            .expect("field")
1680            .default = Some(json!(5));
1681        assert!(!spec.validate().valid);
1682        let mut spec = simple();
1683        spec.flow = Some(Flow::new().step("global").terminal().build().expect("flow"));
1684        assert!(
1685            spec.validate()
1686                .errors
1687                .iter()
1688                .any(|e| e.contains("session-level pipeline"))
1689        );
1690        let mut spec = simple();
1691        spec.state.insert(
1692            "question".into(),
1693            serde_json::from_value(json!({"type":"string","default":"root context"}))
1694                .expect("field"),
1695        );
1696        assert!(
1697            spec.validate()
1698                .errors
1699                .iter()
1700                .any(|error| error.contains("session-level pipeline"))
1701        );
1702    }
1703
1704    #[test]
1705    fn overlapping_contracts_accept_matching_types_and_reject_conflicting_defaults() {
1706        let mut spec = simple();
1707        let field: StateFieldSpec =
1708            serde_json::from_value(json!({"type":"string","default":"same"})).expect("field");
1709        spec.skills[0]
1710            .inputs
1711            .insert("question".into(), field.clone());
1712        spec.skills[0]
1713            .outputs
1714            .insert("question".into(), field.clone());
1715        spec.skills[0].state.insert("question".into(), field);
1716        assert!(spec.validate().valid, "matching overlap is supported");
1717        spec.skills[0]
1718            .outputs
1719            .get_mut("question")
1720            .expect("field")
1721            .default = Some(json!("different"));
1722        assert!(
1723            spec.validate()
1724                .errors
1725                .iter()
1726                .any(|error| error.contains("conflicting defaults"))
1727        );
1728    }
1729
1730    #[tokio::test]
1731    async fn replay_checks_inputs_and_declared_output_without_external_effects() {
1732        let spec = simple();
1733        let steps: Vec<TaskStep> = serde_json::from_value(json!([
1734            {"event":"command","command":{"action":"start","skill":"faq","input":{"question":5}},"expected_error":"wrong type"},
1735            {"event":"command","command":{"action":"start","skill":"faq","input":{"question":"Help"}}},
1736            {"event":"command","command":{"action":"complete","task":"task-1","output":{}},"expected_error":"required field"},
1737            {"event":"command","command":{"action":"invoke","task":"task-1","tool":"lookup","args":{}}},
1738            {"event":"command","command":{"action":"complete","task":"task-1","output":{"should_not_export":true}}},
1739            {"event":"expect","tasks":[{"task":"task-1","status":"completed","output":{"result":{"answer":"help"}}}]}
1740        ])).expect("steps");
1741        let (snapshots, status) = spec.trace_tasks(&steps).await.expect("traces");
1742        assert!(
1743            snapshots.iter().all(|s| s.failures.is_empty()),
1744            "{:?}",
1745            snapshots.iter().map(|s| &s.failures).collect::<Vec<_>>()
1746        );
1747        assert_eq!(
1748            status.tasks.len(),
1749            1,
1750            "rejected activation allocates no task"
1751        );
1752        let (initial, status) = spec.trace_tasks(&[]).await.expect("initial");
1753        assert_eq!(initial.len(), 1);
1754        assert_eq!(status.skills[0].key.version, "1");
1755    }
1756
1757    #[tokio::test]
1758    async fn custom_tool_context_is_owned_and_bound_to_private_invocation_state() {
1759        let resources = SpecResources::default().implement_skill(
1760            "faq",
1761            ContextTool::new("lookup", "lookup", None, |args, ctx| async move {
1762                let owner = ctx.task.as_ref().expect("task ownership forwarded");
1763                let question: String = ctx
1764                    .state
1765                    .try_get("question")
1766                    .map_err(|e| gemini_adk_rs::ToolError::Other(e.to_string()))?
1767                    .ok_or_else(|| gemini_adk_rs::ToolError::Other("missing question".into()))?;
1768                assert!(args.is_object());
1769                Ok(json!({"task":owner.task.0,"question":question}))
1770            }),
1771        );
1772        let mut runtime = simple().compile_tasks(&resources).expect("compiles");
1773        for index in 1..=2 {
1774            runtime
1775                .command(TaskCommand::Start {
1776                    skill: "faq".into(),
1777                    input: json!({"question":format!("question-{index}")}),
1778                    parent: None,
1779                })
1780                .expect("starts");
1781            let id = TaskId(format!("task-{index}"));
1782            let invocation = runtime
1783                .command(TaskCommand::Invoke {
1784                    task: id.clone(),
1785                    tool: "lookup".into(),
1786                    args: json!({}),
1787                    idempotency_key: None,
1788                })
1789                .expect("admits")
1790                .expect("invocation");
1791            let delivery = runtime.complete(invocation.execute().await);
1792            assert!(!delivery.stale);
1793            runtime
1794                .command(TaskCommand::Complete {
1795                    task: id,
1796                    output: json!({}),
1797                })
1798                .expect("completes");
1799        }
1800        let snapshot = runtime.snapshot();
1801        assert_eq!(
1802            snapshot.tasks[0].output,
1803            Some(json!({"result":{"task":"task-1","question":"question-1"}}))
1804        );
1805        assert_eq!(
1806            snapshot.tasks[1].output,
1807            Some(json!({"result":{"task":"task-2","question":"question-2"}}))
1808        );
1809    }
1810
1811    #[tokio::test]
1812    async fn reconciled_commit_applies_declared_state_without_reexecuting_tool() {
1813        use gemini_adk_rs::tasks::{OperationStatus, ReconciledOutcome};
1814        use std::sync::atomic::{AtomicUsize, Ordering};
1815
1816        let mut spec = simple();
1817        spec.skills[0].tools[0].effect = TaskToolEffect::Commit {
1818            idempotency_argument: "request_id".into(),
1819        };
1820        spec.skills[0].tools[0]
1821            .tool
1822            .set_state
1823            .insert("accepted".into(), json!(true));
1824        spec.skills[0].outputs.insert(
1825            "accepted".into(),
1826            serde_json::from_value(json!({"type":"boolean"})).expect("field"),
1827        );
1828        spec.skills[0].outputs.insert(
1829            "unverified".into(),
1830            serde_json::from_value(json!({"type":"boolean","default":false})).expect("field"),
1831        );
1832        let calls = Arc::new(AtomicUsize::new(0));
1833        let counted = calls.clone();
1834        let resources = SpecResources::default().implement_skill(
1835            "faq",
1836            ContextTool::new("lookup", "lookup", None, move |_args, ctx| {
1837                let counted = counted.clone();
1838                async move {
1839                    counted.fetch_add(1, Ordering::SeqCst);
1840                    ctx.state
1841                        .set("unverified", true)
1842                        .map_err(|error| gemini_adk_rs::ToolError::Other(error.to_string()))?;
1843                    Err(gemini_adk_rs::ToolError::Other("connection lost".into()))
1844                }
1845            }),
1846        );
1847        let mut runtime = spec.compile_tasks(&resources).expect("compiles");
1848        runtime
1849            .command(TaskCommand::Start {
1850                skill: "faq".into(),
1851                input: json!({"question":"Help"}),
1852                parent: None,
1853            })
1854            .expect("starts");
1855        runtime
1856            .command(TaskCommand::Invoke {
1857                task: TaskId("task-1".into()),
1858                tool: "lookup".into(),
1859                args: json!({"request_id":"receipt-1"}),
1860                idempotency_key: None,
1861            })
1862            .expect("awaits approval");
1863        let operation = runtime.snapshot().operations[0].id.clone();
1864        let invocation = runtime
1865            .command(TaskCommand::Decide {
1866                operation: operation.clone(),
1867                approve: true,
1868            })
1869            .expect("approves")
1870            .expect("invocation");
1871        runtime.complete(invocation.execute().await);
1872        assert_eq!(
1873            runtime.snapshot().operations[0].status,
1874            OperationStatus::Unknown
1875        );
1876        let verified = json!({"receipt":"externally-verified"});
1877        runtime
1878            .command(TaskCommand::Reconcile {
1879                operation,
1880                outcome: ReconciledOutcome::Succeeded {
1881                    result: verified.clone(),
1882                },
1883            })
1884            .expect("reconstructs accepted state");
1885        runtime
1886            .command(TaskCommand::Complete {
1887                task: TaskId("task-1".into()),
1888                output: json!({}),
1889            })
1890            .expect("exports reconstructed response");
1891        assert_eq!(
1892            runtime.snapshot().tasks[0].output,
1893            Some(json!({"result":verified,"accepted":true,"unverified":false}))
1894        );
1895        assert_eq!(calls.load(Ordering::SeqCst), 1, "reconciliation is pure");
1896    }
1897
1898    #[tokio::test]
1899    async fn support_demo_runs_shared_task_journeys() {
1900        let spec: SessionSpec = serde_json::from_str(include_str!(
1901            "../../../../apps/gemini-adk-web-rs/static/examples/flows/support-agent.json"
1902        ))
1903        .expect("demo parses");
1904        let validation = spec.validate();
1905        assert!(validation.valid, "{:?}", validation.errors);
1906        assert_eq!(validation.steps, 5, "billing and diagnostics governance");
1907        assert_eq!(
1908            validation.tools,
1909            [
1910                "billing__verify_account",
1911                "billing__submit_adjustment",
1912                "faq__search_faq",
1913                "diagnostics__run_diagnostics",
1914                "handoff__transfer_case",
1915            ]
1916        );
1917        for report in spec.run_task_scenarios().await {
1918            assert!(report.passed, "{}: {:?}", report.name, report.error);
1919        }
1920    }
1921
1922    #[test]
1923    fn session_workflow_tests_include_qualified_skill_passes_and_failures() {
1924        let mut spec = simple();
1925        spec.skills[0].flow = Some(
1926            serde_json::from_value(json!({
1927                "steps":[
1928                    {"id":"retrieve","allow":["lookup"],"done":{"called_ok":"lookup"}},
1929                    {"id":"done","after":["retrieve"],"terminal":true}
1930                ],
1931                "constraints":[{"require":["done"]}]
1932            }))
1933            .expect("governed flow"),
1934        );
1935        spec.skills[0].tests = serde_json::from_value(json!([
1936            {"name":"retrieval","script":[{"tool":"lookup"},{"expect":{"complete":true}}]},
1937            {"name":"unfilled","script":[{"expect":{"complete":true}}]}
1938        ]))
1939        .expect("workflow tests");
1940        assert!(spec.validate().valid);
1941        let reports = spec.run_tests();
1942        assert_eq!(reports.len(), 2, "each child test runs once");
1943        assert_eq!(reports[0].name, "faq/retrieval");
1944        assert!(reports[0].passed, "{:?}", reports[0].failures);
1945        assert_eq!(reports[1].name, "faq/unfilled");
1946        assert!(!reports[1].passed);
1947        assert!(!reports[1].failures.is_empty());
1948        assert_eq!(spec.skills[0].run_tests()[0].name, "retrieval");
1949    }
1950
1951    #[test]
1952    fn recursive_sandbox_preserves_effect_policy_and_mock_state_writes() {
1953        let mut spec = simple();
1954        spec.skills[0].tools[0].tool.http = Some(
1955            serde_json::from_value(json!({"url":"http://127.0.0.1:1/blocked"})).expect("binding"),
1956        );
1957        let (safe, notes) = spec.sandboxed(&super::super::BindingAllowlist::default());
1958        assert!(safe.skills[0].tools[0].tool.http.is_none());
1959        assert!(matches!(
1960            safe.skills[0].tools[0].effect,
1961            TaskToolEffect::Read
1962        ));
1963        assert_eq!(
1964            safe.skills[0].tools[0].tool.save_response_as.as_deref(),
1965            Some("result")
1966        );
1967        assert!(notes[0].contains("skill 'faq'"));
1968    }
1969
1970    #[test]
1971    fn unsupported_task_owned_extraction_and_redaction_fail_explicitly() {
1972        let mut spec = simple();
1973        spec.skills[0].conversation = Some(
1974            serde_json::from_value(json!({
1975                "name":"unsupported",
1976                "stages":[{"id":"resolve","resolve":[{"slot":"answer","resolver":"lookup"}]}],
1977                "policies":[{"kind":"redact","keys":["question"]}]
1978            }))
1979            .expect("conversation"),
1980        );
1981        let errors = spec.skills[0]
1982            .validate()
1983            .expect_err("unsupported behavior must fail");
1984        assert!(
1985            errors
1986                .iter()
1987                .any(|error| error.contains("stage frame extraction"))
1988        );
1989        assert!(errors.iter().any(|error| error.contains("redaction")));
1990    }
1991
1992    #[tokio::test]
1993    async fn offline_validation_keeps_conflicting_bindings_visible() {
1994        let mut spec = simple();
1995        spec.skills[0].tools[0].tool.http = Some(
1996            serde_json::from_value(json!({"url":"http://127.0.0.1:1/no-network"}))
1997                .expect("binding"),
1998        );
1999        spec.skills[0].tools[0].tool.mcp = Some("would-run-an-external-command".into());
2000        let errors = spec
2001            .trace_tasks(&[])
2002            .await
2003            .expect_err("conflicting bindings fail before mock replay");
2004        assert!(errors[0].contains("both an http and an mcp binding"));
2005    }
2006    fn service_spec() -> SessionSpec {
2007        SessionSpec::from_value(json!({
2008            "name":"owned-services", "skills":[{
2009                "name":"intake","version":"1",
2010                "outputs":{"name":{"type":"string"},"urgent":{"type":"boolean"},
2011                    "ready":{"type":"boolean"},"noticed":{"type":"boolean"},"stuck":{"type":"boolean"}},
2012                "state":{"urgent":{"type":"boolean","default":false},
2013                    "noticed":{"type":"boolean","default":false},"stuck":{"type":"boolean","default":false}},
2014                "extract":[{"name":"details","instruction":"Extract the name and urgency.","window":2,
2015                    "schema":{"type":"object","properties":{"person":{"type":"string"},"urgent":{"type":"boolean"}}},
2016                    "promote":[{"field":"person","to":"name"},{"field":"urgent","policy":"true_only"}]}],
2017                "computed":[{"key":"ready","from":{"key":"urgent"}}],
2018                "watch":[{"key":"ready","condition":"became_true","set":{"noticed":true},
2019                    "effects":[{"context":"Urgency has been captured."},{"prompt":"Confirm the callback."}]}],
2020                "patterns":[{"name":"two-urgent-turns","when":{"is_true":"urgent"},"turns":2,
2021                    "effects":[{"set":{"stuck":true}}]}]
2022            }]
2023        })).expect("service spec")
2024    }
2025
2026    async fn replay_service_steps(spec: &SessionSpec, steps: Value) -> Vec<TaskTraceSnapshot> {
2027        let steps: Vec<TaskStep> = serde_json::from_value(steps).expect("service steps");
2028        let (trace, _) = spec.trace_tasks(&steps).await.expect("service replay");
2029        assert!(
2030            trace.iter().all(|snapshot| snapshot.failures.is_empty()),
2031            "{trace:#?}"
2032        );
2033        trace
2034    }
2035
2036    #[tokio::test]
2037    async fn task_speech_uses_promotions_computed_watchers_and_turn_patterns() {
2038        let spec = service_spec();
2039        assert!(spec.requires_extraction());
2040        assert!(!spec.requires_memory());
2041        assert!(spec.validate().valid, "{:?}", spec.validate().errors);
2042        assert!(
2043            spec.compile_tasks(&SpecResources::default())
2044                .err()
2045                .expect("missing resources")
2046                .contains("extraction_llm")
2047        );
2048        let trace = replay_service_steps(&spec,json!([
2049            {"event":"command","command":{"action":"start","skill":"intake"}},
2050            {"event":"observe","task":"task-1","user":"My name is Ada and this is urgent",
2051                "extraction":{"details":{"person":"Ada","urgent":true}}},
2052            {"event":"observe","task":"task-1","user":"I actually meant another name but urgency remains",
2053                "extraction":{"details":{"person":"Grace","urgent":false}}},
2054            {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2055            {"event":"expect","tasks":[{"task":"task-1","status":"completed",
2056                "output":{"name":"Ada","urgent":true,"ready":true,"noticed":true,"stuck":true}}]}
2057        ])).await;
2058        let context_count = trace
2059            .iter()
2060            .flat_map(|snapshot| &snapshot.service_effects)
2061            .filter(|effect| matches!(effect.effect, TaskEffect::Context(_)))
2062            .count();
2063        assert_eq!(
2064            context_count, 1,
2065            "computed watcher fires once for the accepted change"
2066        );
2067    }
2068
2069    #[tokio::test]
2070    async fn task_service_replay_keeps_minimum_words_and_reports_unused_fixtures() {
2071        let spec = service_spec();
2072        let steps: Vec<TaskStep> = serde_json::from_value(json!([
2073            {"event":"command","command":{"action":"start","skill":"intake"}},
2074            {"event":"observe","task":"task-1","user":"Hi","extraction":{"details":{"person":"Ada"}}}
2075        ])).expect("steps");
2076        let (trace, _) = spec.trace_tasks(&steps).await.expect("trace");
2077        assert!(
2078            trace[2]
2079                .failures
2080                .iter()
2081                .any(|failure| failure.contains("unused controlled extraction"))
2082        );
2083        assert!(
2084            trace[2].service_effects.is_empty(),
2085            "skipped extraction never promotes fixtures"
2086        );
2087    }
2088
2089    #[tokio::test]
2090    async fn task_service_replay_rejects_late_revision_results_and_isolates_activations() {
2091        let mut spec = service_spec();
2092        spec.skills[0].outputs.clear();
2093        let trace = replay_service_steps(&spec,json!([
2094            {"event":"command","command":{"action":"start","skill":"intake"}},
2095            {"event":"observe","task":"task-1","user":"This is urgent and my name is Ada",
2096                "extraction":{"details":{"person":"Ada","urgent":true}},"defer":true},
2097            {"event":"command","command":{"action":"revise","task":"task-1","expected_revision":1,"input":{}}},
2098            {"event":"finish_service","operation":"service-2"},
2099            {"event":"command","command":{"action":"start","skill":"intake","parent":"task-1"}},
2100            {"event":"observe","task":"task-2","user":"My name is Grace and it is not urgent",
2101                "extraction":{"details":{"person":"Grace","urgent":false}}},
2102            {"event":"command","command":{"action":"complete","task":"task-2","output":{}}},
2103            {"event":"expect","foreground":"task-1","tasks":[{"task":"task-1","revision":2,"status":"running"}]}
2104        ])).await;
2105        assert!(
2106            !trace
2107                .iter()
2108                .flat_map(|snapshot| &snapshot.service_effects)
2109                .any(|effect| matches!(effect.effect, TaskEffect::Prompt(_))),
2110            "discarded worker does not fire watchers"
2111        );
2112    }
2113
2114    #[tokio::test]
2115    async fn task_generation_and_after_tool_extraction_use_their_declared_triggers() {
2116        let mut value = serde_json::to_value(service_spec()).expect("serialize");
2117        let skill = &mut value["skills"][0];
2118        skill["computed"] = json!([]);
2119        skill["watch"] = json!([]);
2120        skill["patterns"] = json!([]);
2121        skill["outputs"] = json!({"name":{"type":"string"},"urgent":{"type":"boolean"}});
2122        skill["tools"] =
2123            json!([{ "name":"lookup","effect":{"kind":"read"},"response":{"ok":true}}]);
2124        skill["extract"][0]["trigger"] = json!("on_generation_complete");
2125        let mut after = skill["extract"][0].clone();
2126        after["name"] = json!("after_lookup");
2127        after["trigger"] = json!("after_tool_call");
2128        skill["extract"]
2129            .as_array_mut()
2130            .expect("extract")
2131            .push(after);
2132        let spec = SessionSpec::from_value(value).expect("spec");
2133        replay_service_steps(&spec,json!([
2134            {"event":"command","command":{"action":"start","skill":"intake"}},
2135            {"event":"observe","task":"task-1","user":"My name is Ada and not urgent"},
2136            {"event":"observe","task":"task-1","trigger":"generation_complete","user":"My name is Ada and not urgent",
2137                "model":"I can help you","extraction":{"details":{"person":"Ada","urgent":false}}},
2138            {"event":"command","command":{"action":"invoke","task":"task-1","tool":"lookup","args":{}},
2139                "extraction":{"after_lookup":{"person":"Grace","urgent":true}}},
2140            {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2141            {"event":"expect","tasks":[{"task":"task-1","output":{"name":"Ada","urgent":true}}]}
2142        ])).await;
2143    }
2144
2145    #[tokio::test]
2146    async fn task_memory_fixtures_project_only_declared_typed_slots() {
2147        let spec = SessionSpec::from_value(json!({"name":"memory","skills":[{
2148            "name":"profile","version":"1","memory":{"slots":[{"predicate":"preferred_name","to":"name"}]},
2149            "outputs":{"name":{"type":"string"}}
2150        }]})).expect("spec");
2151        assert!(spec.requires_memory());
2152        assert!(
2153            spec.compile_tasks(&SpecResources::default())
2154                .err()
2155                .expect("missing resources")
2156                .contains("SpecResources.memory")
2157        );
2158        let trace = replay_service_steps(
2159            &spec,
2160            json!([
2161                {"event":"memory","skill":"profile","values":{"name":"Ada"}},
2162                {"event":"command","command":{"action":"start","skill":"profile"}},
2163                {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2164                {"event":"expect","tasks":[{"task":"task-1","output":{"name":"Ada"}}]}
2165            ]),
2166        )
2167        .await;
2168        let catalog = &trace[0].status.skills[0];
2169        assert_eq!(catalog.tools.len(), 2);
2170        assert!(matches!(
2171            catalog
2172                .tools
2173                .iter()
2174                .find(|tool| tool.name == "manage_memory")
2175                .expect("memory tool")
2176                .effect,
2177            TaskToolEffect::Commit { .. }
2178        ));
2179        let wrong: Vec<TaskStep> = serde_json::from_value(json!([
2180            {"event":"memory","skill":"profile","values":{"name":5}},
2181            {"event":"command","command":{"action":"start","skill":"profile"}}
2182        ]))
2183        .expect("steps");
2184        let (trace, _) = spec.trace_tasks(&wrong).await.expect("trace");
2185        assert!(
2186            trace[2]
2187                .failures
2188                .iter()
2189                .any(|failure| failure.contains("wrong type"))
2190        );
2191    }
2192
2193    #[tokio::test]
2194    async fn task_temporal_service_uses_manual_time_and_fresh_activation_detectors() {
2195        let spec = SessionSpec::from_value(json!({"name":"temporal","skills":[{
2196            "name":"wait","version":"1","inputs":{"waiting":{"type":"boolean"}},
2197            "outputs":{"nudged":{"type":"boolean"}},"state":{"nudged":{"type":"boolean","default":false}},
2198            "patterns":[{"name":"waiting-five-seconds","when":{"is_true":"waiting"},"sustained_secs":5,
2199                "effects":[{"set":{"nudged":true}}]}]
2200        }]})).expect("spec");
2201        replay_service_steps(&spec,json!([
2202            {"event":"command","command":{"action":"start","skill":"wait","input":{"waiting":true}}},
2203            {"event":"observe","task":"task-1","trigger":"timer"},
2204            {"event":"advance_time","millis":5000},
2205            {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2206            {"event":"expect","tasks":[{"task":"task-1","output":{"nudged":true}}]},
2207            {"event":"command","command":{"action":"start","skill":"wait","input":{"waiting":true}}},
2208            {"event":"observe","task":"task-2","trigger":"timer"},
2209            {"event":"command","command":{"action":"complete","task":"task-2","output":{}}},
2210            {"event":"expect","tasks":[{"task":"task-2","output":{"nudged":false}}]}
2211        ])).await;
2212    }
2213
2214    #[test]
2215    fn task_services_roundtrip_and_reject_phase_triggers_before_activation() {
2216        let spec = service_spec();
2217        let encoded = serde_json::to_value(&spec).expect("serialize");
2218        let roundtrip = SessionSpec::from_value(encoded.clone()).expect("parse");
2219        assert_eq!(
2220            serde_json::to_value(&roundtrip).expect("serialize"),
2221            encoded
2222        );
2223        let mut spec = roundtrip;
2224        spec.skills[0].extract[0].trigger = super::super::TriggerSpec::OnPhaseChange;
2225        assert!(
2226            spec.validate()
2227                .errors
2228                .iter()
2229                .any(|error| error.contains("on_phase_change"))
2230        );
2231    }
2232    #[test]
2233    fn task_memory_installs_shared_lifecycle_once_for_multiple_skills() {
2234        use std::sync::atomic::{AtomicUsize, Ordering};
2235        struct Binding {
2236            installs: Arc<AtomicUsize>,
2237        }
2238        impl super::super::MemoryBinding for Binding {
2239            fn install(
2240                &self,
2241                _live: crate::live::Live,
2242                _memory: &super::super::MemorySpec,
2243            ) -> crate::live::Live {
2244                panic!("task mode must not install global memory services")
2245            }
2246            fn remember(&self, _note: String) {
2247                panic!("task remember uses owned work")
2248            }
2249            fn task_memory(
2250                &self,
2251                _memory: &super::super::MemorySpec,
2252            ) -> Result<Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>, String> {
2253                Ok(Arc::new(FixtureMemory::default()))
2254            }
2255            fn task_tools(&self) -> Vec<Arc<dyn ToolFunction>> {
2256                super::super::MEMORY_TOOL_NAMES
2257                    .into_iter()
2258                    .map(|name| {
2259                        Arc::new(gemini_adk_rs::tool::SimpleTool::new(
2260                            name,
2261                            "Controlled memory",
2262                            None,
2263                            |_| async { Ok(json!({})) },
2264                        )) as Arc<dyn ToolFunction>
2265                    })
2266                    .collect()
2267            }
2268            fn install_task_lifecycle(&self, live: crate::live::Live) -> crate::live::Live {
2269                self.installs.fetch_add(1, Ordering::SeqCst);
2270                live
2271            }
2272        }
2273        let spec = SessionSpec::from_value(json!({"name":"memory","skills":[
2274            {"name":"profile","version":"1","memory":{}},
2275            {"name":"preferences","version":"1","memory":{}}
2276        ]}))
2277        .expect("spec");
2278        let installs = Arc::new(AtomicUsize::new(0));
2279        let resources = SpecResources {
2280            memory: Some(Arc::new(Binding {
2281                installs: installs.clone(),
2282            })),
2283            ..Default::default()
2284        };
2285        assert!(
2286            spec.apply(crate::live::Live::builder(), &State::new(), &resources)
2287                .is_ok()
2288        );
2289        assert_eq!(installs.load(Ordering::SeqCst), 1);
2290    }
2291    #[test]
2292    fn task_services_cannot_author_writes_into_runtime_storage() {
2293        let mut spec = service_spec();
2294        spec.skills[0].extract[0].promote[0].to = Some("task:input".into());
2295        spec.skills[0].watch[0]
2296            .set
2297            .insert("flow:done:gate".into(), json!(true));
2298        spec.skills[0].patterns[0]
2299            .effects
2300            .push(super::super::EffectSpec::Set(BTreeMap::from([(
2301                "derived:ready".into(),
2302                json!(true),
2303            )])));
2304        let errors = spec.validate().errors;
2305        for key in ["task:input", "flow:done:gate", "derived:ready"] {
2306            assert!(errors.iter().any(|error| error.contains(key)), "{errors:?}");
2307        }
2308    }
2309}