gemini_adk_fluent_rs/
testing.rs

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