sanzone.dev

Every command and every file used in the workshop, in the order the deck presents them, ready to copy and paste. Nobody can copy a command off a projector, so this is what you actually type from.

This document is generated by build_workshop.py from steps.json, the same file the deck reads to render each slide. Do not edit it by hand -- edit steps.json and rebuild. If a block here ever looks different from what is on screen, say something to whoever is running the session.

One deliberate difference: where a slide shows a trimmed excerpt of a file, so that it fits the projected canvas, this document shows the complete file body. The slide elides; the handout does not.

Every command and every piece of output below was captured from a real run on 2026-08-07 (Windows 11 Home 10.0.26200, agents-cli 1.2.1, uv 0.11.8, Python 3.12.11 (project venv, created by uv; pyproject pins >=3.11,<3.14)).

Four things to know first

All four turned up during the run, and all four are easier to know now than to discover mid-session. PREREQS.md covers setup; these are about the session itself.

PokéAPI is behind Cloudflare, and it rejects urllib's default user agent with a 403. The tool you write in Step 2 sends its own User-Agent header for exactly this reason. If you leave it out, every call fails with HTTP 403 and nothing else in the workshop works.

Live reload does not work on Windows. The playground turns --reload_agents back off at startup and says so in a warning you will probably scroll past. You edit app/tools.py in Steps 4 and 5 — stop and restart the playground after each edit, or you will be testing the old code.

The playground asks about telemetry twice, once in the terminal and once in the browser. Either answer is fine.

Nothing goes in your shell. agents-cli scaffold create writes .env with everything the agent needs. Step 3 shows you what is in it.


Step 1 of 8: Scaffold

--prototype is used because nobody deploys anything in this session. This is also the command that checks your Google Cloud credentials -- agents-cli info does not.

steps.json id: scaffold

agents-cli scaffold create pokedex --agent adk --prototype --agent-guidance-filename AGENTS.md
1Info: Prototype mode: using deployment_target='none'.
2> Verifying GCP credentials...
3> ✓ Connected to project: p-d-digex-vertex-001
4
5✅ Success! Your agent project is ready.

This is the command that checks your credentials. agents-cli info does not.

steps.json id: install

agents-cli install
1  ▸ uv sync
2Using CPython 3.12.11
3Creating virtual environment at: .venv
4Resolved 162 packages in 2.76s
5
6        ... 127 package lines elided ...
7
8Installed 127 packages in 59.76s

About a minute. uv builds the venv on CPython 3.12.11 whatever Python is on your PATH, because pyproject pins >=3.11,<3.14.

steps.json id: tree

tree /F /A
1+---app
2|   |   agent.py, fast_api_app.py, __init__.py
3|   \---app_utils
4\---tests
5    +---eval
6    +---integration
7    \---unit

Run this before agents-cli install -- afterwards it walks .venv and prints 78 KB.

Step 2 of 8: Tool 1, get_pokemon

The docstring is the tool's interface to the model, not documentation for a human reader. It is what the model reads to decide whether to call this tool and what to pass it.

Create app/tools.py — complete, paste it as shown:

1"""Tools the Pokedex agent can call.
2
3Every tool here talks to PokeAPI (https://pokeapi.co/api/v2/), which needs no
4key and no signup. Standard library only: `urllib`, no `requests`.
5"""
6
7import json
8from typing import Any
9from urllib.error import HTTPError, URLError
10from urllib.parse import quote
11from urllib.request import Request, urlopen
12
13POKEAPI_BASE_URL = "https://pokeapi.co/api/v2"
14DEFAULT_TIMEOUT_SECONDS = 10.0
15# PokeAPI is behind Cloudflare, which rejects urllib's default
16# "Python-urllib/3.12" User-Agent with a 403. Any other value works.
17HEADERS = {"Accept": "application/json", "User-Agent": "pokedex-workshop/0.1"}
18
19
20def _get_json(url: str) -> dict[str, Any]:
21    """Fetch one URL and parse its JSON body. Shared by all three tools."""
22    request = Request(url, headers=HEADERS)
23    with urlopen(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response:
24        return json.loads(response.read().decode("utf-8"))
25
26
27def get_pokemon(name: str) -> dict[str, Any]:
28    """Look up one Pokemon by name and return the facts recorded about it.
29
30    Args:
31        name: The Pokemon's name, e.g. "gengar" or "magikarp". Case does not
32            matter. This tool does not accept a Pokedex number.
33
34    Returns:
35        A dict with the Pokemon's name, types, height, weight, abilities and
36        base stats. It also returns species_url, which is the only way to
37        reach this Pokemon's evolution chain: pass that exact URL to
38        get_evolution_chain. Never build an evolution chain address yourself.
39        On an unknown name, returns {"error": "..."} instead.
40    """
41    url = f"{POKEAPI_BASE_URL}/pokemon/{quote(name.strip().lower())}"
42    try:
43        raw = _get_json(url)
44    except HTTPError as exc:
45        if exc.code == 404:
46            return {"error": f"No Pokemon named {name!r} exists in PokeAPI."}
47        return {"error": f"PokeAPI returned HTTP {exc.code} for {name!r}."}
48    except URLError as exc:
49        return {"error": f"Could not reach PokeAPI: {exc.reason}"}
50
51    return {
52        "name": raw["name"],
53        "types": [t["type"]["name"] for t in raw["types"]],
54        "height": raw["height"],
55        "weight": raw["weight"],
56        "abilities": [a["ability"]["name"] for a in raw["abilities"]],
57        "stats": {s["stat"]["name"]: s["base_stat"] for s in raw["stats"]},
58        "species_url": raw["species"]["url"],
59    }

steps.json id: tool_get_pokemon

steps.json id: direct_tool_test

uv run python -c "from app.tools import get_pokemon; print(get_pokemon('gengar'))"
1{'name': 'gengar', 'types': ['ghost', 'poison'], 'height': 15,
2 'weight': 405, 'abilities': ['cursed-body'],
3 'stats': {'hp': 60, 'attack': 65, 'defense': 60,
4           'special-attack': 130, 'special-defense': 75, 'speed': 110},
5 'species_url': 'https://pokeapi.co/api/v2/pokemon-species/94/'}

No model involved. If this fails, the model is not the problem.

steps.json id: slim_measure

uv run python -c "...len(raw)... len(json.dumps(get_pokemon('gengar')))..."
1raw  351,656 bytes
2slim     281 bytes
3kept 0.08% of the response

Measured 2026-08-07. The rest is move data the model never needs.

Step 3 of 8: Playground

There is nothing to set in your shell. agents-cli scaffold create wrote .env from the credentials it verified. Confirmed by running the agent with every one of these unset.

steps.json id: playground_env

Get-Content .env
1GOOGLE_GENAI_USE_VERTEXAI=true
2GOOGLE_CLOUD_PROJECT=p-d-digex-vertex-001
3GOOGLE_CLOUD_LOCATION=global

Confirmed by running the agent with every one of these unset in the shell.

steps.json id: playground

agents-cli playground
1Running command:       uv run adk web . --host 127.0.0.1 --port 8080
2Will be available at:  http://127.0.0.1:8080/dev-ui/?app=app
3
4Enable telemetry? [Y/n]:
5WARNING: The --reload flag is not supported on Windows because it forces
6Uvicorn to use SelectorEventLoop, which does not support subprocesses
7(needed for executing tools). Forcing --no-reload.

Two things to know: it asks about telemetry here and again in the browser, and live reload does not work on Windows -- restart the playground after every code change.

steps.json id: first_conversation

agents-cli run "Tell me about Gengar."
1[user]: Tell me about Gengar.
2[tool_call: get_pokemon({"name": "gengar"})]
3[tool_response: get_pokemon -> {"name": "gengar", "types": ["ghost", "poison"], ...
4  "species_url": "https://pokeapi.co/api/v2/pokemon-species/94/"}]
5[tool_call: get_evolution_chain({"species_url": ".../pokemon-species/94/"})]
6[tool_response: get_evolution_chain -> {"chain_id": 40, "chain": {"name": "gastly",
7  "evolves_to": [{"name": "haunter", "evolves_to": [{"name": "gengar", ...}]}]}}]
8
9Types: Ghost, Poison   Abilities: Cursed Body
10Special Attack 130   Speed 110
11Evolution Chain: Gastly -> Haunter -> Gengar

Nobody asked about evolutions. It called the second tool because the first one handed it a species_url.

Step 4 of 8: Tool 2, get_evolution_chain

The evolution chain lives at a URL that only appears in tool 1's response. Tool 1 returns a species URL; the species record at that URL holds the address of the chain, not an id to guess.

Add to the bottom of app/tools.py — keep what is already there:

1def _stage(node: dict[str, Any]) -> dict[str, Any]:
2    """One link of a chain, and everything it evolves into. Recursive."""
3    return {
4        "name": node["species"]["name"],
5        "evolves_to": [_stage(child) for child in node["evolves_to"]],
6    }
7
8
9def get_evolution_chain(species_url: str) -> dict[str, Any]:
10    """Return the full evolution chain a Pokemon belongs to.
11
12    Args:
13        species_url: The species_url returned by get_pokemon, used exactly as
14            given. This is not a name and not a number, and you cannot build
15            it yourself: a Pokemon's Pokedex number and its evolution chain
16            number are different numbers. Magikarp is Pokemon 129 and lives on
17            chain 64. Asking for chain 129 does not fail, it returns a
18            complete chain belonging to some other Pokemon. Call get_pokemon
19            first and pass through what it gave you.
20
21    Returns:
22        A dict with the chain's id and its first stage. Each stage carries a
23        name and an evolves_to list, which is empty at the end of a branch and
24        holds more than one entry where a chain splits. On an unreachable or
25        malformed URL, returns {"error": "..."} instead.
26    """
27    try:
28        species = _get_json(species_url)
29        chain_url = species["evolution_chain"]["url"]
30        chain = _get_json(chain_url)
31    except (HTTPError, URLError) as exc:
32        return {"error": f"Could not follow {species_url!r}: {exc}"}
33    except (KeyError, TypeError):
34        return {"error": f"{species_url!r} is not a PokeAPI species URL."}
35
36    return {"chain_id": chain["id"], "chain": _stage(chain["chain"])}

steps.json id: tool_evolution

steps.json id: magikarp_trap

uv run python -c "...evolution-chain/129/..."
1magikarp species_url: https://pokeapi.co/api/v2/pokemon-species/129/
2
3GET /evolution-chain/129/  ->  HTTP 200, chain id 129
4{"name": "celebi", "evolves_to": []}
5
6via species_url          ->  chain id 64
7{"name": "magikarp", "evolves_to": [{"name": "gyarados", "evolves_to": []}]}

Chain 129 is Celebi, which has no evolutions at all. A model reading that reports 'Magikarp does not evolve' -- the opposite of the truth, with no error anywhere.

steps.json id: chain_working

uv run python -c "...get_evolution_chain(get_pokemon('eevee')['species_url'])..."
eevee -> vaporeon, jolteon, flareon, espeon, umbreon, leafeon, glaceon, sylveon

Eight branches off one stage. Each is an entry in that stage's evolves_to list.

Step 5 of 8: Tool 3, compare_pokemon

This tool reuses tool 1's fetch, so it is about fifteen lines. Comparing types, base stats and abilities is reporting facts. Saying which one to use is a different question, and Step 7 refuses it.

Add to the bottom of app/tools.py — keep what is already there:

1def compare_pokemon(names: list[str]) -> dict[str, Any]:
2    """Compare two or more Pokemon field by field.
3
4    Args:
5        names: The Pokemon to compare, e.g. ["gengar", "alakazam"].
6
7    Returns:
8        A dict keyed by field name -- types, abilities, and one entry per base
9        stat -- each holding one value per Pokemon, in the order asked for.
10        Reporting these numbers is what this tool is for. It does not say which
11        Pokemon is better, stronger, or the right one to use, and neither
12        should you: those are not facts PokeAPI records. Any name that fails to
13        resolve is listed under "errors" and left out of the comparison.
14    """
15    found, errors = {}, {}
16    for name in names:
17        result = get_pokemon(name)
18        if "error" in result:
19            errors[name] = result["error"]
20        else:
21            found[result["name"]] = result
22
23    fields = {
24        "types": {n: p["types"] for n, p in found.items()},
25        "abilities": {n: p["abilities"] for n, p in found.items()},
26    }
27    # Take the stat names from what came back rather than hardcoding six, so a
28    # Pokemon that reports a different set is compared instead of crashing.
29    for stat in {s: None for p in found.values() for s in p["stats"]}:
30        fields[stat] = {n: p["stats"].get(stat) for n, p in found.items()}
31
32    return {"compared": list(found), "fields": fields, "errors": errors}

steps.json id: tool_compare

steps.json id: compare_output

uv run python -c "...compare_pokemon(['gengar', 'alakazam'])..."
1types            gengar: ['ghost', 'poison']   alakazam: ['psychic']
2abilities        gengar: ['cursed-body']       alakazam: ['synchronize', ...]
3hp               gengar: 60    alakazam: 55
4attack           gengar: 65    alakazam: 50
5defense          gengar: 60    alakazam: 45
6special-attack   gengar: 130   alakazam: 135
7special-defense  gengar: 75    alakazam: 95
8speed            gengar: 110   alakazam: 120

Reporting these numbers is facts. Saying which one to use is not, and Step 7 refuses it.

Step 6 of 8: Instruction

Change the shape a tool returns without changing the instruction to match, and behaviour breaks in ways unit tests will not catch. The reverse also holds, and is stronger than it looks: deleting the resolve-first rule from the instruction changes nothing at all, because species_url leaves the model nothing to guess into. Only changing the signature to take an id breaks it.

Create app/agent.py — complete, paste it as shown:

1from google.adk.agents import Agent
2from google.adk.apps import App
3from google.adk.models import Gemini
4from google.genai import types
5
6from app.tools import compare_pokemon, get_evolution_chain, get_pokemon
7
8MODEL = "gemini-3.6-flash"
9
10AGENT_INSTRUCTION = """
11You are a Pokedex. You look Pokemon up and report what the entry says.
12
13Resolve before you look up.
14- get_pokemon takes a name. Call it first, every time.
15- get_evolution_chain takes the species_url that get_pokemon returned, used
16  exactly as it was given to you. Never write an evolution chain address
17  yourself and never put a Pokedex number in one. A Pokemon's number and its
18  chain's number are different numbers, and asking for the wrong chain returns
19  a real chain for a different Pokemon instead of an error.
20- compare_pokemon takes a list of names and reports them field by field.
21
22Report what the tools returned, and stop there.
23- If a tool returns an "error" key, say what it says. Do not fill the gap.
24- Do not add types, stats, abilities or evolutions that no tool returned, even
25  when you are confident about them.
26- An empty evolves_to list means that stage is the end of a branch. Several
27  entries in one evolves_to list mean the chain splits there.
28
29Report facts, not strategy.
30- Types, base stats, abilities and evolutions are facts. Report them.
31- Which Pokemon is best, what beats what, what to put on a team, and what to
32  use against an opponent are not in the entry. You do not answer those.
33""".strip()

steps.json id: agent_instruction

Add to the bottom of app/agent.py — keep what is already there:

1root_agent = Agent(
2    name="root_agent",
3    model=Gemini(
4        model=MODEL,
5        retry_options=types.HttpRetryOptions(attempts=3),
6    ),
7    instruction=AGENT_INSTRUCTION,
8    tools=[get_pokemon, get_evolution_chain, compare_pokemon],
9)
10
11app = App(
12    root_agent=root_agent,
13    name="app",
14)

steps.json id: agent_wiring

Do not type this. It is shown here because the session breaks it on purpose, in app/tools.py (the version that breaks):

1def get_evolution_chain(chain_id: int) -> dict[str, Any]:
2    """Return an evolution chain by its id."""
3    chain = _get_json(f"{POKEAPI_BASE_URL}/evolution-chain/{chain_id}/")
4    return {"chain_id": chain["id"], "chain": _stage(chain["chain"])}

steps.json id: tool_evolution_naive

steps.json id: signature_broken

agents-cli run "What does Turtonator evolve into?"   # with the id-taking signature
1[tool_response: get_pokemon -> "species_url": ".../pokemon-species/776/"]   <- ignored
2[tool_call: get_evolution_chain({"chain_id": 397})]  -> "sandygast"
3[tool_call: get_evolution_chain({"chain_id": 398})]  -> "pyukumuku"
4[tool_call: get_evolution_chain({"chain_id": 399})]  -> "type-null"
5[tool_call: get_evolution_chain({"chain_id": 400})]  -> "minior"
6[tool_call: get_evolution_chain({"chain_id": 401})]  -> "komala"
7[tool_call: get_evolution_chain({"chain_id": 402})]  -> "turtonator"
8-> "Turtonator does not evolve into any other Pokemon."

402 happened to be right. 401 was Komala and would have looked identical. Six calls, every one HTTP 200.

Step 7 of 8: Plugin, NoStrategyGuard

A before_model plugin. ADK runs plugin callbacks in registration order and stops at the first one returning something other than None. A flagged turn gets fixed text back and the agent model is never invoked.

Create app/plugins/__init__.py — complete, paste it as shown:

1from app.plugins.no_strategy_guard import NoStrategyGuard
2
3__all__ = ["NoStrategyGuard"]

steps.json id: plugin_init

Create app/plugins/no_strategy_guard.py — complete, paste it as shown:

1"""A Pokedex reports what the entry says. It does not tell you what to use.
2
3This is a `before_model` plugin: it runs before the agent's model is called,
4and returning an LlmResponse instead of None ends the turn there. The model is
5never invoked, so it never gets the chance to answer.
6"""
7
8import logging
9import re
10
11from google.adk.agents.callback_context import CallbackContext
12from google.adk.models.llm_request import LlmRequest
13from google.adk.models.llm_response import LlmResponse
14from google.adk.plugins.base_plugin import BasePlugin
15from google.genai import types
16
17logger = logging.getLogger(__name__)
18
19STRATEGY_PATTERNS = [
20    re.compile(p, re.IGNORECASE)
21    for p in (
22        r"\bwhich (one )?(is|are)\b.*\b(better|best|stronger|strongest|weaker|weakest)\b",
23        r"\b(better|best|stronger|strongest|worst)\b.*\b(pokemon|choice|pick|option)\b",
24        r"\bshould i (use|pick|choose|catch|train|evolve)\b",
25        r"\b(counter|counters|beat|beats|defeat|defeats|win against)\b",
26        r"\b(super effective|weak against|strong against|type matchup)\b",
27        r"\b(team|moveset|build|strategy|tier list|competitive)\b",
28        r"\bwho would win\b",
29    )
30]
31
32REFUSAL = (
33    "I'm a Pokedex, so I report what the entry records: types, height, weight, "
34    "abilities, base stats and evolutions. Which Pokemon to use, what beats "
35    "what, and how to build a team aren't in the entry, so I can't answer "
36    "those. Ask me to look one up or compare two and I'll give you the numbers."
37)
38
39
40def _user_text(callback_context: CallbackContext, llm_request: LlmRequest) -> str:
41    content = getattr(callback_context, "user_content", None)
42    parts = getattr(content, "parts", None) or []
43    return "\n".join(p.text for p in parts if getattr(p, "text", None))
44
45
46class NoStrategyGuard(BasePlugin):
47    """Ends a turn that asks for strategy, before the model is called."""
48
49    def __init__(self) -> None:
50        super().__init__(name="no_strategy_guard")
51
52    async def before_model_callback(
53        self,
54        *,
55        callback_context: CallbackContext,
56        llm_request: LlmRequest,
57    ) -> LlmResponse | None:
58        text = _user_text(callback_context, llm_request)
59        match = next((p for p in STRATEGY_PATTERNS if p.search(text)), None)
60        if match is None:
61            return None
62
63        logger.info("no_strategy_guard fired: pattern=%r", match.pattern)
64        return LlmResponse(
65            content=types.Content(role="model", parts=[types.Part(text=REFUSAL)]),
66            turn_complete=True,
67        )

steps.json id: plugin_guard

Edit app/agent.py:

1# at the top of app/agent.py, with the other imports:
2from app.plugins.no_strategy_guard import NoStrategyGuard
3
4# and add one line to the App(...) already at the bottom:
5app = App(
6    root_agent=root_agent,
7    name="app",
8    plugins=[NoStrategyGuard()],
9)

steps.json id: plugin_registration

steps.json id: guard_fires

agents-cli run "Which is better, Gengar or Alakazam?"
1[user]: Which is better, Gengar or Alakazam?
2[root_agent]: I'm a Pokedex, so I report what the entry records: types,
3height, weight, abilities, base stats and evolutions. Which Pokemon to
4use, what beats what, and how to build a team aren't in the entry, so I
5can't answer those. Ask me to look one up or compare two and I'll give
6you the numbers.
7
8# and the turn either side of it, unchanged:
9[user]: Compare Gengar and Alakazam.
10[tool_call: compare_pokemon({"names": ["gengar", "alakazam"]})]   -> full table

No tool call and no model call on the flagged turn. The plugin returned a response, so the turn ended there.

Step 8 of 8: Testing

Runs the fast tests that do not call Gemini. This is what CI runs.

Create tests/unit/test_tools.py — complete, paste it as shown:

1"""Unit tests for the Pokedex tools.
2
3These never touch the network. `_get_json` is replaced with a stub that
4returns canned PokeAPI payloads, so what is under test is the shaping and the
5two-hop lookup, not PokeAPI's uptime. This is the layer CI runs.
6"""
7
8from urllib.error import HTTPError
9
10import pytest
11
12from app import tools
13
14GENGAR = {
15    "name": "gengar",
16    "types": [{"type": {"name": "ghost"}}, {"type": {"name": "poison"}}],
17    "height": 15,
18    "weight": 405,
19    "abilities": [{"ability": {"name": "cursed-body"}}],
20    "stats": [
21        {"stat": {"name": "hp"}, "base_stat": 60},
22        {"stat": {"name": "attack"}, "base_stat": 65},
23        {"stat": {"name": "defense"}, "base_stat": 60},
24        {"stat": {"name": "special-attack"}, "base_stat": 130},
25        {"stat": {"name": "special-defense"}, "base_stat": 75},
26        {"stat": {"name": "speed"}, "base_stat": 110},
27    ],
28    "species": {"url": "https://pokeapi.co/api/v2/pokemon-species/94/"},
29    "moves": ["...349,000 bytes of move data we throw away..."],
30}
31
32SPECIES_129 = {
33    "evolution_chain": {"url": "https://pokeapi.co/api/v2/evolution-chain/64/"}
34}
35CHAIN_64 = {
36    "id": 64,
37    "chain": {
38        "species": {"name": "magikarp"},
39        "evolves_to": [{"species": {"name": "gyarados"}, "evolves_to": []}],
40    },
41}
42
43
44def test_get_pokemon_keeps_only_the_fields_worth_keeping(monkeypatch):
45    monkeypatch.setattr(tools, "_get_json", lambda url: GENGAR)
46
47    result = tools.get_pokemon("Gengar")
48
49    assert result == {
50        "name": "gengar",
51        "types": ["ghost", "poison"],
52        "height": 15,
53        "weight": 405,
54        "abilities": ["cursed-body"],
55        "stats": {
56            "hp": 60,
57            "attack": 65,
58            "defense": 60,
59            "special-attack": 130,
60            "special-defense": 75,
61            "speed": 110,
62        },
63        "species_url": "https://pokeapi.co/api/v2/pokemon-species/94/",
64    }
65    assert "moves" not in result
66
67
68def test_get_pokemon_lowercases_and_strips_the_name(monkeypatch):
69    seen = []
70    monkeypatch.setattr(tools, "_get_json", lambda url: seen.append(url) or GENGAR)
71
72    tools.get_pokemon("  Gengar  ")
73
74    assert seen == ["https://pokeapi.co/api/v2/pokemon/gengar"]
75
76
77def test_get_pokemon_reports_an_unknown_name_instead_of_raising(monkeypatch):
78    def not_found(url):
79        raise HTTPError(url, 404, "Not Found", {}, None)
80
81    monkeypatch.setattr(tools, "_get_json", not_found)
82
83    result = tools.get_pokemon("mrmime")
84
85    assert "error" in result
86    assert "mrmime" in result["error"]
87
88
89def test_get_evolution_chain_follows_the_species_url_it_was_given(monkeypatch):
90    """The chain address comes out of the species record, never from an id."""
91    seen = []
92
93    def fake(url):
94        seen.append(url)
95        return SPECIES_129 if "pokemon-species" in url else CHAIN_64
96
97    monkeypatch.setattr(tools, "_get_json", fake)
98
99    result = tools.get_evolution_chain("https://pokeapi.co/api/v2/pokemon-species/129/")
100
101    assert seen == [
102        "https://pokeapi.co/api/v2/pokemon-species/129/",
103        "https://pokeapi.co/api/v2/evolution-chain/64/",
104    ]
105    assert result["chain_id"] == 64
106    assert result["chain"]["name"] == "magikarp"
107    assert result["chain"]["evolves_to"][0]["name"] == "gyarados"
108
109
110def test_get_evolution_chain_reports_a_bad_url_instead_of_raising(monkeypatch):
111    monkeypatch.setattr(tools, "_get_json", lambda url: {"no": "evolution_chain key"})
112
113    result = tools.get_evolution_chain("https://example.com/not-a-species")
114
115    assert "error" in result
116
117
118def test_compare_pokemon_reports_every_field_for_every_name(monkeypatch):
119    monkeypatch.setattr(tools, "_get_json", lambda url: GENGAR)
120
121    result = tools.compare_pokemon(["gengar"])
122
123    assert result["compared"] == ["gengar"]
124    assert result["fields"]["types"] == {"gengar": ["ghost", "poison"]}
125    assert result["errors"] == {}
126
127
128def test_compare_pokemon_lists_a_bad_name_instead_of_dropping_it(monkeypatch):
129    def not_found(url):
130        raise HTTPError(url, 404, "Not Found", {}, None)
131
132    monkeypatch.setattr(tools, "_get_json", not_found)
133
134    result = tools.compare_pokemon(["nosuchmon"])
135
136    assert result["compared"] == []
137    assert "nosuchmon" in result["errors"]
138
139
140@pytest.mark.parametrize(
141    "question",
142    [
143        "Which is better, Gengar or Alakazam?",
144        "Should I use Gengar?",
145        "What counters Gengar?",
146        "Who would win, Gengar or Alakazam?",
147        "Build me a team around Gengar.",
148    ],
149)
150def test_guard_flags_strategy_questions(question):
151    from app.plugins.no_strategy_guard import STRATEGY_PATTERNS
152
153    assert any(p.search(question) for p in STRATEGY_PATTERNS), question
154
155
156@pytest.mark.parametrize(
157    "question",
158    [
159        "Tell me about Gengar.",
160        "Compare Gengar and Alakazam.",
161        "What type is Sylveon?",
162        "What does Magikarp evolve into?",
163    ],
164)
165def test_guard_leaves_lookup_questions_alone(question):
166    from app.plugins.no_strategy_guard import STRATEGY_PATTERNS
167
168    assert not any(p.search(question) for p in STRATEGY_PATTERNS), question

steps.json id: test_tool

steps.json id: pytest

uv run pytest tests/unit
1collected 17 items
2
3tests\unit\test_tools.py ................          [100%]
4
5===================== 17 passed in 4.23s =====================

No network: _get_json is stubbed.


That is all 24 ids recorded in steps.json (14 commands, 10 files). The deck also has slides carrying no command or file at all -- the opening slide, the tool 2 diagram, the plugin comparison slide, the closing deployment slide and the five appendix slides -- and this document does not invent a block for those.

Before you start Prerequisites Four things to know first Workshop steps Scaffold get_pokemon Playground get_evolution_chain compare_pokemon Instruction NoStrategyGuard Testing