test(gemini): live cassette hook-system stress suite - #2013
Merged
Conversation
Adds a Gemini cassette-backed stress suite (tests/providers/gemini/cassette/hook_stress.rs) that exercises the merged hook system (v2, #2012) across long, realistic multi-turn workflows recorded against real Gemini and replayed deterministically: - HookContext identity (stable run_id, advancing turn, is_streaming, agent_name) and a shared Scratchpad threaded across two cooperating hooks and many turns. - RequestPatch: extra_context injection + active_tools narrowing + temperature, proven by downstream effects (the injected fact reaches the answer; the filtered-out tool never executes). - Chained tool lifecycle: RewriteArgs -> observe -> RewriteResult redaction, with paired positive/negative assertions (the marker reaches the model; the raw result does not; the transcript keeps the model's original args). - Streaming lifecycle ordering (tool call -> execution start -> tool result -> final response) and is_streaming parity vs the blocking surface. - Per-turn atomic call/result pairing and the Skip zero-execution invariant. Assertions follow tools_support's loose-assertion convention (exact equality only for rig-synthesized values), so the cassettes survive re-recording; hooks are deterministic so replay requests stay byte-identical. Cassettes are auto-scrubbed and safety-checked by the recorder (key -> [REDACTED]; ids/signatures placeholdered) and were reviewed manually. No hook-system bugs surfaced: every scenario passed on the first live recording, confirming the documented behavior. Inspired by how LangChain, LangGraph, OpenAI Agents, pydantic-ai, Semantic Kernel, and the Vercel AI SDK test their hook/middleware/guardrail systems (ordered breadcrumb logs; proving mutations via downstream effects; paired positive+negative redaction; zero-downstream skip invariants).
Broadens the Gemini hook-system stress suite from 6 to 30 recorded workflows across four themed cassette files, driven by a shared deterministic-fixtures module (hook_stress_support.rs): - hook_stress_context (8): HookContext identity (stable run_id, advancing turn, is_streaming, agent_name incl. the unset case); a shared Scratchpad written by one hook and read by a second, growing across turns; internal_call_id correlation of ToolCall/ToolResult; two observe-only hooks both firing; add_hook appending across builder + request; CompletionCall patch accumulation; and active_tools set-intersection across two narrowing hooks. - hook_stress_patch (4): preamble override; tool_choice=Required (first turn only); per-turn history replacement injecting a prior fact; multi-field patch (preamble + extra_context). - hook_stress_tools (6): single-key and chained RewriteArgs; chained RewriteResult (redact -> wrap) and truncation; Terminate from a ToolResult (post-execution); and model-driven recovery from a tool error. - hook_stress_streaming (6): TextDelta / StreamResponseFinish / ModelTurnFinished on streaming; ModelTurnFinished on tool turns; RewriteResult redaction reaching the FinalResponse; active_tools narrowing and Skip on the streaming driver; and blocking-vs-streaming answer parity (two cassettes). All recorded live against Gemini and replaying deterministically; every patch / rewrite / skip effect is proven by a downstream-observable change, and model-shaped values use loose assertions (exact only for rig-synthesized ones). Cassettes are auto-scrubbed + safety-checked by the recorder and were reviewed. A footgun surfaced along the way: forcing tool_choice=Required on *every* turn loops until max_turns (each turn re-forces a tool call). Captured with a first-turn-only patch fixture (FirstTurnPatch) so the intended pattern is shown. No hook-system bugs found; every scenario confirms the documented behavior.
This was referenced Jul 5, 2026
Merged
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A Gemini cassette-backed stress-test suite for the merged hook system (v2, #2012): 30 long, realistic multi-turn workflows recorded against a real Gemini model and replayed deterministically (no API key needed for replay). Five cassette files under
tests/providers/gemini/cassette/(hook_stress,hook_stress_context,hook_stress_patch,hook_stress_tools,hook_stress_streaming) + 31 cassette fixtures, driven by a shared deterministic-fixtures module (hook_stress_support.rs). No production code changes.Inspiration-library test patterns studied
I studied how six libraries test their hook/middleware/callback/guardrail systems and adopted the transferable assertion strategies (a recorded real Gemini is the deterministic script the fake-model scripting can't be):
(event, turn); paired countersWhat the new Gemini hook stress tests cover
hook_stress.rs(6) — the original core: HookContext + Scratchpad across a long chain; RequestPatch extra_context + active_tools narrowing; chained RewriteArgs→observe→RewriteResult redaction; streaming lifecycle ordering + is_streaming parity; per-turn atomic call/result pairing; Skip zero-execution.hook_stress_context.rs(8) — HookContext identity (stablerun_id, advancingturn,is_streaming,agent_nameincl. the unset case); a sharedScratchpadwritten by one hook and read by a second, growing across turns;internal_call_idcorrelation ofToolCall↔ToolResult; two observe-only hooks both firing;add_hookappending across builder + request;CompletionCallpatch accumulation;active_toolsset-intersection across two narrowing hooks.hook_stress_patch.rs(4) — preamble override;tool_choice=Required(first turn only); per-turnhistoryreplacement injecting a prior fact; a multi-field patch (preamble + extra_context).hook_stress_tools.rs(6) — single-key and chainedRewriteArgs; chainedRewriteResult(redact→wrap) and truncation;Terminatefrom aToolResult(post-execution — the tool body ran first); and model-driven recovery from a tool error (the tool errors with guidance → the model retries → succeeds).hook_stress_streaming.rs(6) —TextDelta/StreamResponseFinish/ModelTurnFinishedon the streaming surface;ModelTurnFinishedon tool turns;RewriteResultredaction reaching theFinalResponse;active_toolsnarrowing andSkipon the streaming driver; and blocking-vs-streaming answer parity (two cassettes).Aspects covered: HookContext (all accessors + unset name), Scratchpad (cross-hook, cross-turn, keyed correlation), HookStack composition (multi-hook order, both-fire, add_hook append, chained rewrites, patch accumulation, active_tools intersection), RequestPatch (preamble / temperature / tool_choice / active_tools / extra_context / history), tool lifecycle (arg rewrite / result redact-wrap-truncate / skip / terminate-on-result / tool-error recovery / execution start), streaming lifecycle + blocking-vs-streaming parity.
Bugs found and fixed
No hook-system bugs. Every scenario passed on live recording, confirming the merged system behaves as documented. One footgun surfaced: forcing
tool_choice=Requiredon every turn loops untilmax_turns(each turn re-forces a tool call). It's expected per the per-turn/re-fire semantics, so rather than change production code I captured the intended pattern with aFirstTurnPatchfixture and a comment. No production code changed.Cassette safety
RIG_PROVIDER_TEST_MODE=record+GEMINI_API_KEY, then auto-scrubbed and safety-checked by the recorder before writing (the run fails if unsafe artifacts remain).keyquery param →[REDACTED];thoughtSignature/responseId→ placeholders; grep across all fixtures forx-goog-api-key/gemini_api_key/AIza/authorization/bearer/set-cookieand the raw key value all return empty.Commands run
RIG_PROVIDER_TEST_MODE=record cargo test -p rig --all-features --test gemini hook_stress_<mod> -- --test-threads=1→ all passed.cargo test -p rig --all-features --test gemini hook_stress -- --test-threads=1→ 30 passed (0.18s, deterministic).cargo test -p rig --all-features --test gemini gemini::cassette -- --test-threads=1→ 115 passed, 0 failed.cargo fmt(clean) ·cargo clippy -p rig --all-features --test gemini(clean).Remaining risks / flakiness considerations
Retry/Repair/Skip/Failvia unregistered/disallowed calls — Gemini won't emit those on purpose; covered byagent_run_recovery.rs+ rig-coreagent::runnerunit tests) and structured-output Tool-mode finalization (structured_output.rs+ rig-core unit tests forModelTurnFinishedcontent and the output-tool stream item). Per the guidance to prefer a few rich workflows over many flaky tiny live tests.