gemini_adk_fluent_rs/
testing.rs

1//! Testing utilities — mock backends, agent harnesses, contract validation.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::builder::AgentBuilder;
6
7/// Contract violation detected during static analysis.
8#[derive(Debug, Clone, PartialEq)]
9pub enum ContractViolation {
10    /// A consumer reads a key that no producer writes.
11    UnproducedKey {
12        /// Name of the agent that reads the unproduced key.
13        consumer: String,
14        /// The state key that is read but never written.
15        key: String,
16    },
17    /// Multiple agents write to the same key (race condition risk).
18    DuplicateWrite {
19        /// Names of agents that write to the same key.
20        agents: Vec<String>,
21        /// The contested state key.
22        key: String,
23    },
24    /// A producer writes to a key that no consumer reads (dead output).
25    OrphanedOutput {
26        /// Name of the agent that writes the orphaned key.
27        producer: String,
28        /// The state key that is written but never read.
29        key: String,
30    },
31}
32
33/// Check state contracts across a set of agents.
34///
35/// Validates that:
36/// - Every key a consumer reads is produced by some agent
37/// - No two agents write the same key (race condition detection)
38/// - Every key a producer writes is consumed by some agent (dead code detection)
39pub fn check_contracts(agents: &[AgentBuilder]) -> Vec<ContractViolation> {
40    let mut violations = Vec::new();
41
42    // Collect all writes and reads
43    let mut all_writes: HashMap<String, Vec<String>> = HashMap::new();
44    let mut all_reads: HashSet<String> = HashSet::new();
45    let mut all_written_keys: HashSet<String> = HashSet::new();
46
47    for agent in agents {
48        for key in agent.get_writes() {
49            all_writes
50                .entry(key.clone())
51                .or_default()
52                .push(agent.name().to_string());
53            all_written_keys.insert(key.clone());
54        }
55        for key in agent.get_reads() {
56            all_reads.insert(key.clone());
57        }
58    }
59
60    // Check for unproduced keys (consumer reads what nobody writes)
61    for agent in agents {
62        for key in agent.get_reads() {
63            if !all_written_keys.contains(key) {
64                violations.push(ContractViolation::UnproducedKey {
65                    consumer: agent.name().to_string(),
66                    key: key.clone(),
67                });
68            }
69        }
70    }
71
72    // Check for duplicate writes
73    for (key, writers) in &all_writes {
74        if writers.len() > 1 {
75            violations.push(ContractViolation::DuplicateWrite {
76                agents: writers.clone(),
77                key: key.clone(),
78            });
79        }
80    }
81
82    // Check for orphaned outputs (producer writes, nobody reads)
83    for agent in agents {
84        for key in agent.get_writes() {
85            if !all_reads.contains(key) {
86                violations.push(ContractViolation::OrphanedOutput {
87                    producer: agent.name().to_string(),
88                    key: key.clone(),
89                });
90            }
91        }
92    }
93
94    violations
95}
96
97/// Infer data flow between agents based on reads/writes declarations.
98///
99/// Returns a list of `(producer, consumer, key)` tuples representing data dependencies.
100pub fn infer_data_flow(agents: &[AgentBuilder]) -> Vec<DataFlowEdge> {
101    let mut edges = Vec::new();
102
103    for producer in agents {
104        for consumer in agents {
105            if producer.name() == consumer.name() {
106                continue;
107            }
108            for write_key in producer.get_writes() {
109                if consumer.get_reads().contains(write_key) {
110                    edges.push(DataFlowEdge {
111                        producer: producer.name().to_string(),
112                        consumer: consumer.name().to_string(),
113                        key: write_key.clone(),
114                    });
115                }
116            }
117        }
118    }
119
120    edges
121}
122
123/// A data flow edge between two agents.
124#[derive(Debug, Clone, PartialEq)]
125pub struct DataFlowEdge {
126    /// The agent that writes the key.
127    pub producer: String,
128    /// The agent that reads the key.
129    pub consumer: String,
130    /// The state key.
131    pub key: String,
132}
133
134/// A test harness for running agents with controlled inputs.
135pub struct AgentHarness {
136    state: gemini_adk_rs::State,
137}
138
139impl AgentHarness {
140    /// Create a new harness with empty state.
141    pub fn new() -> Self {
142        Self {
143            state: gemini_adk_rs::State::new(),
144        }
145    }
146
147    /// Set a state value before running.
148    pub fn set<V: serde::Serialize>(self, key: &str, value: V) -> Self {
149        let _ = self.state.set(key, value);
150        self
151    }
152
153    /// Get the underlying state.
154    pub fn state(&self) -> &gemini_adk_rs::State {
155        &self.state
156    }
157
158    /// Run a text agent against this harness state.
159    pub async fn run(
160        &self,
161        agent: &dyn gemini_adk_rs::text::TextAgent,
162    ) -> Result<String, gemini_adk_rs::error::AgentError> {
163        agent.run(&self.state).await
164    }
165}
166
167impl Default for AgentHarness {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173/// Diagnostic utility — returns a summary of an agent builder's configuration.
174pub fn diagnose(agent: &AgentBuilder) -> String {
175    let mut lines = Vec::new();
176    lines.push(format!("Agent: {}", agent.name()));
177
178    if let Some(model) = agent.get_model() {
179        lines.push(format!("  Model: {:?}", model));
180    }
181    if let Some(inst) = agent.get_instruction() {
182        let truncated = if inst.len() > 80 {
183            format!("{}...", &inst[..80])
184        } else {
185            inst.to_string()
186        };
187        lines.push(format!("  Instruction: {}", truncated));
188    }
189    if let Some(t) = agent.get_temperature() {
190        lines.push(format!("  Temperature: {}", t));
191    }
192    if agent.tool_count() > 0 {
193        lines.push(format!("  Tools: {}", agent.tool_count()));
194    }
195    if !agent.get_writes().is_empty() {
196        lines.push(format!("  Writes: {:?}", agent.get_writes()));
197    }
198    if !agent.get_reads().is_empty() {
199        lines.push(format!("  Reads: {:?}", agent.get_reads()));
200    }
201    if !agent.get_sub_agents().is_empty() {
202        lines.push(format!("  Sub-agents: {}", agent.get_sub_agents().len()));
203    }
204
205    lines.join("\n")
206}
207
208// ─── Live session contracts ─────────────────────────────────────────────────
209
210/// A misconfiguration found in a [`Live`](crate::live::Live) builder, before
211/// connecting.
212///
213/// The counterpart to [`ContractViolation`] for voice sessions. `check_contracts`
214/// only ever saw `AgentBuilder`, so the configuration where cross-referencing
215/// actually matters — phases, a governing flow, memory slots, watchers, all
216/// naming each other by string — had no static check at all.
217#[derive(Debug, Clone, PartialEq)]
218pub enum LiveViolation {
219    /// A governing flow names a tool this session does not register.
220    ///
221    /// Not inert: a step whose `allow` list contains only names that match
222    /// nothing denies *every* tool for as long as that step is active.
223    FlowToolNotRegistered {
224        /// The name the flow uses.
225        tool: String,
226        /// What the session actually registers, for spotting a typo.
227        registered: Vec<String>,
228    },
229    /// Phases were configured but no initial phase was named, so **every phase
230    /// is silently discarded at connect** and the session runs unphased.
231    PhasesWithoutInitialPhase {
232        /// The phases that will be dropped.
233        phases: Vec<String>,
234    },
235    /// `initial_phase` names a phase that was never defined. The machine starts
236    /// in a state with no instruction, no tools and no transitions.
237    UnknownInitialPhase {
238        /// The name given to `initial_phase`.
239        name: String,
240        /// The phases that do exist.
241        defined: Vec<String>,
242    },
243    /// A phase no transition targets and which is not the initial phase — it
244    /// can never be entered.
245    UnreachablePhase {
246        /// The phase that cannot be reached.
247        name: String,
248    },
249    /// A phase transition targets a phase that does not exist.
250    UnknownTransitionTarget {
251        /// The phase declaring the transition.
252        from: String,
253        /// The target that does not exist.
254        target: String,
255    },
256    /// Some tools resolve over the network at connect, so name-based checks
257    /// here are working from a partial registry.
258    ///
259    /// Advisory, not an error: it reports that the check could not be complete,
260    /// which is worth saying out loud rather than implying full coverage.
261    ToolsUnresolvedAtCheckTime {
262        /// How many tools will only exist after connect.
263        count: usize,
264    },
265}
266
267impl std::fmt::Display for LiveViolation {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        match self {
270            Self::FlowToolNotRegistered { tool, registered } => write!(
271                f,
272                "the governing flow names tool `{tool}`, which this session does not register. \
273                 Registered: [{}]. A step whose `allow` list matches nothing denies every tool \
274                 while it is active.",
275                registered.join(", ")
276            ),
277            Self::PhasesWithoutInitialPhase { phases } => write!(
278                f,
279                "{} phase(s) defined ({}) but no `initial_phase(..)` — every one of them is \
280                 discarded at connect and the session runs unphased.",
281                phases.len(),
282                phases.join(", ")
283            ),
284            Self::UnknownInitialPhase { name, defined } => write!(
285                f,
286                "`initial_phase(\"{name}\")` names a phase that does not exist. Defined: [{}].",
287                defined.join(", ")
288            ),
289            Self::UnreachablePhase { name } => write!(
290                f,
291                "phase `{name}` is not the initial phase and no transition targets it — it can \
292                 never be entered."
293            ),
294            Self::UnknownTransitionTarget { from, target } => write!(
295                f,
296                "phase `{from}` transitions to `{target}`, which does not exist."
297            ),
298            Self::ToolsUnresolvedAtCheckTime { count } => write!(
299                f,
300                "{count} tool(s) resolve at connect (MCP/A2A/OpenAPI), so tool-name checks here \
301                 are partial. Re-check after connect, or expect connect to catch the rest."
302            ),
303        }
304    }
305}
306
307/// Statically check a configured [`Live`](crate::live::Live) session.
308///
309/// Cross-references the parts that name each other by string — the flow's tool
310/// names against the registered tools, phase transitions against defined
311/// phases, `initial_phase` against both — and reports what cannot line up. Call
312/// it in a test, before the session ever connects:
313///
314/// ```
315/// # use gemini_adk_fluent_rs::live::Live;
316/// # use gemini_adk_fluent_rs::testing::check_live;
317/// let session = Live::builder().instruction("Be helpful.");
318/// assert!(check_live(&session).is_empty());
319/// ```
320///
321/// Connect enforces the tool-name half itself and will refuse a mismatched
322/// flow; this exists so the failure arrives in a unit test instead of at the
323/// first connection attempt. The phase checks have no runtime counterpart —
324/// forgetting `initial_phase` discards every phase in silence.
325pub fn check_live(live: &crate::live::Live) -> Vec<LiveViolation> {
326    let mut violations = Vec::new();
327
328    let registered = live.declared_tool_names();
329    if live.pending_tool_count() > 0 {
330        violations.push(LiveViolation::ToolsUnresolvedAtCheckTime {
331            count: live.pending_tool_count(),
332        });
333    }
334
335    // Flow tool names. `compile_with_tools` already owns this reasoning
336    // (including ambient tools and every constraint kind), so borrow it rather
337    // than re-walking the vocabulary and drifting from it.
338    if let Some(flow) = live.flow() {
339        let mut flow = flow.clone();
340        crate::live::merge_ambient_for_check(&mut flow, live.ambient_tool_names());
341        let names: Vec<&str> = registered.iter().map(String::as_str).collect();
342        if let Err(errors) = flow.compile_with_tools(&names) {
343            for error in &errors.0 {
344                if let gemini_adk_rs::flow::FlowError::UnknownTool(tool) = error {
345                    violations.push(LiveViolation::FlowToolNotRegistered {
346                        tool: tool.clone(),
347                        registered: registered.clone(),
348                    });
349                }
350            }
351        }
352    }
353
354    // Phases.
355    let defined: Vec<String> = live.phases().iter().map(|p| p.name.clone()).collect();
356    match live.initial_phase_name() {
357        None if !defined.is_empty() => {
358            violations.push(LiveViolation::PhasesWithoutInitialPhase {
359                phases: defined.clone(),
360            });
361        }
362        Some(initial) if !defined.iter().any(|p| p == initial) => {
363            violations.push(LiveViolation::UnknownInitialPhase {
364                name: initial.to_string(),
365                defined: defined.clone(),
366            });
367        }
368        _ => {}
369    }
370
371    let mut targeted: HashSet<String> = HashSet::new();
372    for phase in live.phases() {
373        for transition in &phase.transitions {
374            targeted.insert(transition.target.clone());
375            if !defined.contains(&transition.target) {
376                violations.push(LiveViolation::UnknownTransitionTarget {
377                    from: phase.name.clone(),
378                    target: transition.target.clone(),
379                });
380            }
381        }
382    }
383    if let Some(initial) = live.initial_phase_name() {
384        for phase in live.phases() {
385            if phase.name != initial && !targeted.contains(&phase.name) {
386                violations.push(LiveViolation::UnreachablePhase {
387                    name: phase.name.clone(),
388                });
389            }
390        }
391    }
392
393    violations
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn no_violations_for_matching_contracts() {
402        let writer = AgentBuilder::new("writer").writes("output");
403        let reader = AgentBuilder::new("reader").reads("output");
404        let violations = check_contracts(&[writer, reader]);
405        assert!(violations.is_empty());
406    }
407
408    #[test]
409    fn detects_unproduced_key() {
410        let reader = AgentBuilder::new("reader").reads("missing");
411        let violations = check_contracts(&[reader]);
412        assert_eq!(violations.len(), 1);
413        assert!(matches!(
414            &violations[0],
415            ContractViolation::UnproducedKey {
416                consumer,
417                key,
418            } if consumer == "reader" && key == "missing"
419        ));
420    }
421
422    #[test]
423    fn detects_duplicate_write() {
424        let a = AgentBuilder::new("a").writes("shared");
425        let b = AgentBuilder::new("b").writes("shared").reads("shared");
426        let violations = check_contracts(&[a, b]);
427        assert!(violations.iter().any(
428            |v| matches!(v, ContractViolation::DuplicateWrite { key, .. } if key == "shared")
429        ));
430    }
431
432    #[test]
433    fn detects_orphaned_output() {
434        let writer = AgentBuilder::new("writer").writes("unused");
435        let violations = check_contracts(&[writer]);
436        assert_eq!(violations.len(), 1);
437        assert!(matches!(
438            &violations[0],
439            ContractViolation::OrphanedOutput {
440                producer,
441                key,
442            } if producer == "writer" && key == "unused"
443        ));
444    }
445
446    #[test]
447    fn multiple_violations() {
448        let a = AgentBuilder::new("a").writes("orphan");
449        let b = AgentBuilder::new("b").reads("missing");
450        let violations = check_contracts(&[a, b]);
451        assert_eq!(violations.len(), 2);
452    }
453
454    #[test]
455    fn empty_agents_no_violations() {
456        let violations = check_contracts(&[]);
457        assert!(violations.is_empty());
458    }
459
460    #[test]
461    fn infer_data_flow_finds_edges() {
462        let writer = AgentBuilder::new("writer").writes("output");
463        let reader = AgentBuilder::new("reader").reads("output");
464        let edges = infer_data_flow(&[writer, reader]);
465        assert_eq!(edges.len(), 1);
466        assert_eq!(edges[0].producer, "writer");
467        assert_eq!(edges[0].consumer, "reader");
468        assert_eq!(edges[0].key, "output");
469    }
470
471    #[test]
472    fn infer_data_flow_no_self_edges() {
473        let agent = AgentBuilder::new("self").writes("key").reads("key");
474        let edges = infer_data_flow(&[agent]);
475        assert!(edges.is_empty());
476    }
477
478    #[test]
479    fn diagnose_basic() {
480        let agent = AgentBuilder::new("test")
481            .instruction("Be helpful")
482            .temperature(0.5)
483            .writes("output");
484        let diag = diagnose(&agent);
485        assert!(diag.contains("test"));
486        assert!(diag.contains("Be helpful"));
487        assert!(diag.contains("0.5"));
488    }
489
490    #[test]
491    fn harness_sets_state() {
492        let harness = AgentHarness::new().set("key", "value");
493        let val: Option<String> = harness.state().get("key");
494        assert_eq!(val, Some("value".into()));
495    }
496
497    #[test]
498    fn complex_pipeline_contracts() {
499        let researcher = AgentBuilder::new("researcher")
500            .writes("findings")
501            .writes("sources");
502        let writer = AgentBuilder::new("writer")
503            .reads("findings")
504            .writes("draft");
505        let reviewer = AgentBuilder::new("reviewer")
506            .reads("draft")
507            .writes("quality");
508
509        let violations = check_contracts(&[researcher, writer, reviewer]);
510        // "sources" is orphaned (nobody reads it), "quality" is orphaned (nobody reads it)
511        let orphans: Vec<_> = violations
512            .iter()
513            .filter(|v| matches!(v, ContractViolation::OrphanedOutput { .. }))
514            .collect();
515        assert_eq!(orphans.len(), 2);
516    }
517
518    // ─── check_live ─────────────────────────────────────────────────────────
519
520    use crate::live::Live;
521    use gemini_adk_rs::flow::{Flow, Guard};
522
523    fn book_tool() -> crate::compose::tools::ToolComposite {
524        crate::compose::T::simple("book_table", "Book a table", |_| async {
525            Ok(serde_json::json!({"ok": true}))
526        })
527    }
528
529    #[test]
530    fn a_clean_session_reports_nothing() {
531        let live = Live::builder()
532            .instruction("Be helpful.")
533            .with_tools(book_tool())
534            .govern(
535                Flow::new()
536                    .step("book")
537                    .allow(["book_table"])
538                    .done(Guard::called_ok("book_table"))
539                    .build()
540                    .expect("valid"),
541            )
542            .phase("greet")
543            .instruction("Say hello")
544            .done()
545            .initial_phase("greet");
546        assert_eq!(check_live(&live), vec![]);
547    }
548
549    #[test]
550    fn a_flow_tool_typo_is_caught_before_connecting() {
551        let live = Live::builder().with_tools(book_tool()).govern(
552            Flow::new()
553                .step("book")
554                .allow(["book_tabel"])
555                .done(Guard::called_ok("book_tabel"))
556                .build()
557                .expect("valid shape"),
558        );
559        let found = check_live(&live);
560        assert!(
561            found.iter().any(|v| matches!(
562                v,
563                LiveViolation::FlowToolNotRegistered { tool, .. } if tool == "book_tabel"
564            )),
565            "{found:?}"
566        );
567        assert!(
568            found[0].to_string().contains("book_table"),
569            "the message must show what is registered so the typo is visible: {}",
570            found[0]
571        );
572    }
573
574    #[test]
575    fn ambient_tools_are_counted_as_the_session_will_see_them() {
576        // `check_live` must check the flow connect will actually run, which
577        // includes builder-registered ambient tools — otherwise it reports a
578        // failure that will not happen.
579        let live = Live::builder()
580            .with_tools(book_tool())
581            .ambient_tools(["book_table"])
582            .govern(
583                Flow::new()
584                    .step("book")
585                    .done(Guard::called_ok("book_table"))
586                    .build()
587                    .expect("valid"),
588            );
589        assert_eq!(check_live(&live), vec![]);
590    }
591
592    #[test]
593    fn phases_without_an_initial_phase_are_reported_as_discarded() {
594        // The whole set is dropped at connect, in silence. CLAUDE.md lists
595        // forgetting `initial_phase` as a common mistake; nothing enforced it.
596        let live = Live::builder()
597            .phase("greet")
598            .instruction("Say hello")
599            .done()
600            .phase("main")
601            .instruction("Help")
602            .done();
603        let found = check_live(&live);
604        assert!(
605            found.iter().any(|v| matches!(
606                v,
607                LiveViolation::PhasesWithoutInitialPhase { phases } if phases.len() == 2
608            )),
609            "{found:?}"
610        );
611        assert!(found[0].to_string().contains("discarded"), "{}", found[0]);
612    }
613
614    #[test]
615    fn an_initial_phase_naming_nothing_is_reported() {
616        let live = Live::builder()
617            .phase("greet")
618            .instruction("Say hello")
619            .done()
620            .initial_phase("greting");
621        assert!(check_live(&live).iter().any(
622            |v| matches!(v, LiveViolation::UnknownInitialPhase { name, .. } if name == "greting")
623        ));
624    }
625
626    #[test]
627    fn an_unreachable_phase_is_reported() {
628        let live = Live::builder()
629            .phase("greet")
630            .instruction("Say hello")
631            .done()
632            .phase("orphan")
633            .instruction("Never entered")
634            .done()
635            .initial_phase("greet");
636        assert!(check_live(&live)
637            .iter()
638            .any(|v| matches!(v, LiveViolation::UnreachablePhase { name } if name == "orphan")));
639    }
640
641    #[test]
642    fn a_transition_to_a_missing_phase_is_reported() {
643        let live = Live::builder()
644            .phase("greet")
645            .instruction("Say hello")
646            .transition("mian", |_| true)
647            .done()
648            .phase("main")
649            .instruction("Help")
650            .done()
651            .initial_phase("greet");
652        let found = check_live(&live);
653        assert!(
654            found.iter().any(|v| matches!(
655                v,
656                LiveViolation::UnknownTransitionTarget { target, .. } if target == "mian"
657            )),
658            "{found:?}"
659        );
660    }
661
662    #[test]
663    fn an_unphased_session_is_not_reported() {
664        // No phases at all is the ordinary shape, not a mistake.
665        let live = Live::builder().instruction("Be helpful.");
666        assert_eq!(check_live(&live), vec![]);
667    }
668}