chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,17 @@
"""Pytest config for google-adk agent callback tests.
Adds the package's `src/` to sys.path so `from agents.main import ...` resolves
exactly the same way the runtime entry point does (see `src/agent_server.py`).
"""
from __future__ import annotations
import os
import sys
_PKG_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_SRC_DIR = os.path.join(_PKG_DIR, "src")
if _PKG_DIR not in sys.path:
sys.path.insert(0, _PKG_DIR)
if _SRC_DIR not in sys.path:
sys.path.insert(0, _SRC_DIR)
@@ -0,0 +1,247 @@
"""Regression tests for the v0.9 A2UI operations shape.
`tools/generate_a2ui.py:build_a2ui_operations_from_tool_call` must emit
the v0.9 NESTED operation shape — `{"createSurface": {...}}` /
`{"updateComponents": {...}}` / `{"updateDataModel": {...}}` — not the
legacy v0.8 flat shape (`{"type": "create_surface", "surfaceId": ...}`).
The `@ag-ui/a2ui-middleware` matcher (`getOperationSurfaceId`) walks
ONLY the nested keys. Pre-fix, ADK emitted the flat shape, the matcher
returned undefined for every op, the runtime grouped them under the
`"default"` surface, and the React renderer threw
`Catalog not found: default` or `Component 'undefined' is missing an 'id'`.
These tests pin three properties:
1. Shape (v0.9 nested with `version: "v0.9"`).
2. `_sanitize_a2ui_components` drops entries without `id` + `component`.
3. `_unstringify_json_fields` round-trips Gemini's quirk of emitting
`"data": "[{...}]"` as a JSON string back to a real array.
"""
from __future__ import annotations
from tools.generate_a2ui import (
_has_root_component,
_sanitize_a2ui_components,
_unstringify_json_fields,
build_a2ui_operations_from_tool_call,
)
# ---------------------------------------------------------------------------
# Shape: v0.9 nested operations
# ---------------------------------------------------------------------------
def test_build_emits_v09_nested_create_surface():
args = {
"surfaceId": "sales-dash",
"catalogId": "declarative-gen-ui-catalog",
"components": [{"id": "root", "component": "PieChart"}],
}
result = build_a2ui_operations_from_tool_call(args)
ops = result["a2ui_operations"]
create = ops[0]
assert create.get("version") == "v0.9"
# Nested shape — middleware matcher reads surfaceId from inside the
# `createSurface` key, not from the top level.
assert "createSurface" in create
assert create["createSurface"]["surfaceId"] == "sales-dash"
assert create["createSurface"]["catalogId"] == "declarative-gen-ui-catalog"
# Legacy flat shape MUST NOT be present.
assert "type" not in create
assert "surfaceId" not in create # surfaceId is nested, not top-level
def test_build_emits_v09_nested_update_components():
args = {
"surfaceId": "s1",
"catalogId": "c1",
"components": [
{"id": "root", "component": "Card", "children": ["a", "b"]},
{"id": "a", "component": "Metric", "label": "Revenue", "value": "$42k"},
{"id": "b", "component": "Metric", "label": "Signups", "value": "1200"},
],
}
result = build_a2ui_operations_from_tool_call(args)
update = result["a2ui_operations"][1]
assert update.get("version") == "v0.9"
assert "updateComponents" in update
assert update["updateComponents"]["surfaceId"] == "s1"
assert len(update["updateComponents"]["components"]) == 3
def test_build_emits_v09_update_data_model_with_path_and_value():
"""Per copilotkit.a2ui Python SDK shape, updateDataModel uses
`path` + `value`, NOT a flat `data` field."""
args = {
"surfaceId": "s1",
"catalogId": "c1",
"components": [{"id": "root", "component": "PieChart"}],
"data": {"regions": [{"label": "NA", "value": 45}]},
}
result = build_a2ui_operations_from_tool_call(args)
update_data = result["a2ui_operations"][2]
assert update_data.get("version") == "v0.9"
assert "updateDataModel" in update_data
payload = update_data["updateDataModel"]
assert payload["surfaceId"] == "s1"
assert payload["path"] == "/"
assert payload["value"] == {"regions": [{"label": "NA", "value": 45}]}
def test_build_omits_update_data_model_when_args_have_no_data():
args = {
"surfaceId": "s1",
"catalogId": "c1",
"components": [{"id": "root", "component": "PieChart"}],
}
result = build_a2ui_operations_from_tool_call(args)
ops = result["a2ui_operations"]
# Only createSurface + updateComponents — no updateDataModel.
assert len(ops) == 2
assert all("updateDataModel" not in op for op in ops)
# ---------------------------------------------------------------------------
# Sanitization: drop empties + missing id/component
# ---------------------------------------------------------------------------
def test_sanitize_drops_empty_objects():
"""Gemini emits `[{}, {}, {}]` when the components schema lacks
required item fields. The sanitizer must drop them all."""
raw = [{}, {}, {}]
assert _sanitize_a2ui_components(raw) == []
def test_sanitize_drops_entries_missing_id():
raw = [
{"component": "Card"}, # missing id
{"id": "root", "component": "Card"},
]
out = _sanitize_a2ui_components(raw)
assert len(out) == 1
assert out[0]["id"] == "root"
def test_sanitize_drops_entries_missing_component():
raw = [
{"id": "root"}, # missing component
{"id": "x", "component": "Metric"},
]
out = _sanitize_a2ui_components(raw)
assert len(out) == 1
assert out[0]["component"] == "Metric"
def test_sanitize_passes_through_well_formed_entries():
raw = [
{"id": "root", "component": "PieChart"},
{"id": "legend", "component": "Legend"},
]
out = _sanitize_a2ui_components(raw)
assert len(out) == 2
def test_sanitize_returns_empty_for_non_list_input():
"""LLM occasionally returns the components arg as a string when the
schema item type is loose. Don't crash — return empty so the caller
logs a warning and the renderer doesn't error."""
assert _sanitize_a2ui_components("not a list") == []
assert _sanitize_a2ui_components(None) == []
assert _sanitize_a2ui_components({"id": "root"}) == []
def test_has_root_component_true_when_root_id_present():
components = [
{"id": "root", "component": "Card"},
{"id": "leaf", "component": "Metric"},
]
assert _has_root_component(components) is True
def test_has_root_component_false_when_no_root_id():
components = [
{"id": "header", "component": "Title"},
{"id": "body", "component": "Card"},
]
assert _has_root_component(components) is False
# ---------------------------------------------------------------------------
# Unstringify: Gemini emits `"data": "[{...}]"` (string) — must end up array
# ---------------------------------------------------------------------------
def test_unstringify_parses_json_array_string():
component = {
"id": "root",
"component": "PieChart",
"data": '[{"label": "NA", "value": 45}, {"label": "EMEA", "value": 30}]',
}
out = _unstringify_json_fields(component)
assert isinstance(out["data"], list)
assert out["data"][0] == {"label": "NA", "value": 45}
def test_unstringify_parses_json_object_string():
component = {
"id": "x",
"component": "Metric",
"value": '{"amount": 42, "currency": "USD"}',
}
out = _unstringify_json_fields(component)
assert isinstance(out["value"], dict)
assert out["value"]["amount"] == 42
def test_unstringify_leaves_real_arrays_alone():
component = {
"id": "root",
"component": "PieChart",
"data": [{"label": "NA", "value": 45}],
}
out = _unstringify_json_fields(component)
assert out["data"] == [{"label": "NA", "value": 45}]
def test_unstringify_leaves_plain_strings_alone():
"""Non-JSON string fields (like `label`, `text`) must not be touched."""
component = {
"id": "x",
"component": "Metric",
"label": "Revenue",
"value": "$42k", # not JSON-shaped, leave as-is
}
out = _unstringify_json_fields(component)
assert out["label"] == "Revenue"
assert out["value"] == "$42k"
def test_unstringify_leaves_malformed_json_as_string():
"""If `data` looks JSON-ish but doesn't parse, leave the string in place
so the renderer at least receives a defined value instead of nothing."""
component = {
"id": "root",
"component": "PieChart",
"data": "[malformed json",
}
out = _unstringify_json_fields(component)
assert out["data"] == "[malformed json"
def test_sanitize_unstringifies_data_field_end_to_end():
"""The full sanitize path drops empties AND unstringifies in one pass."""
raw = [
{}, # dropped
{
"id": "root",
"component": "PieChart",
"data": '[{"label": "Asia", "value": 25}]',
},
{"id": "no-component"}, # dropped
]
out = _sanitize_a2ui_components(raw)
assert len(out) == 1
assert out[0]["data"] == [{"label": "Asia", "value": 25}]
@@ -0,0 +1,260 @@
"""Unit tests for simple_after_model_modifier.
Covers the truth table of (partial, has_text, has_function_call) × role
and asserts that `end_invocation` is flipped exactly when expected.
end_invocation should be set to True iff ALL of:
- llm_response.content and parts present
- partial == False (or absent)
- role == "model"
- has_text is True
- has_function_call is False
In all other cases, end_invocation must not be flipped.
"""
from __future__ import annotations
import logging
from types import SimpleNamespace
import pytest
from agents.main import simple_after_model_modifier
class FakeInvocationContext:
"""Stub for ADK's private _invocation_context — only carries end_invocation."""
def __init__(self) -> None:
self.end_invocation = False
class FakeCallbackContext:
def __init__(self, agent_name: str = "SalesPipelineAgent") -> None:
self.agent_name = agent_name
self._invocation_context = FakeInvocationContext()
def _make_part(*, text: str | None = None, function_call: object | None = None):
# google.genai Part is a pydantic model with optional fields; SimpleNamespace
# is enough because the callback uses getattr() to read them.
part = SimpleNamespace()
if text is not None:
part.text = text
else:
part.text = None
if function_call is not None:
part.function_call = function_call
else:
part.function_call = None
return part
def _make_response(
*,
role: str = "model",
has_text: bool = False,
has_function_call: bool = False,
partial: bool = False,
with_parts: bool = True,
error_message: str | None = None,
finish_reason: object = "STOP",
):
"""Build a fake LlmResponse.
`finish_reason` defaults to "STOP" — the real terminal-response shape.
`simple_after_model_modifier` / `stop_on_terminal_text` only terminate
when finish_reason is STOP, to avoid premature termination on Gemini
thinking-mode chunks that arrive non-partial with `finish_reason=None`.
"""
parts = []
if with_parts:
parts.append(
_make_part(
text="hello" if has_text else None,
function_call=SimpleNamespace(name="get_weather")
if has_function_call
else None,
)
)
content = SimpleNamespace(role=role, parts=parts) if with_parts else None
response = SimpleNamespace(
content=content,
partial=partial,
error_message=error_message,
finish_reason=finish_reason,
turn_complete=None,
)
return response
# ---------------------------------------------------------------------------
# Terminal case — the ONLY combination that should flip end_invocation.
# ---------------------------------------------------------------------------
def test_flips_end_invocation_on_final_text_only_model_response():
ctx = FakeCallbackContext()
response = _make_response(
role="model", has_text=True, has_function_call=False, partial=False
)
result = simple_after_model_modifier(ctx, response)
assert result is None
assert ctx._invocation_context.end_invocation is True
# ---------------------------------------------------------------------------
# partial × has_text × has_function_call truth table (role=model)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("partial", "has_text", "has_function_call", "expected_end"),
[
# (partial=False already covered above for the True case)
(False, True, True, False), # text + function_call => must NOT terminate
(False, False, True, False), # function_call only => tool call pending
(False, False, False, False), # empty parts => nothing to terminate on
(True, True, False, False), # partial text => wait for turn_complete
(True, True, True, False), # partial text + fc => wait
(True, False, True, False), # partial fc => wait
(True, False, False, False), # partial empty => wait
],
)
def test_truth_table_model_role(partial, has_text, has_function_call, expected_end):
ctx = FakeCallbackContext()
response = _make_response(
role="model",
has_text=has_text,
has_function_call=has_function_call,
partial=partial,
)
simple_after_model_modifier(ctx, response)
assert ctx._invocation_context.end_invocation is expected_end
# ---------------------------------------------------------------------------
# role != "model" => never terminate, even on final text-only responses.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("role", ["user", "tool", ""])
def test_non_model_role_never_terminates(role):
ctx = FakeCallbackContext()
response = _make_response(
role=role, has_text=True, has_function_call=False, partial=False
)
simple_after_model_modifier(ctx, response)
assert ctx._invocation_context.end_invocation is False
# ---------------------------------------------------------------------------
# Non-SalesPipelineAgent agents should be a no-op entirely.
# ---------------------------------------------------------------------------
# Note: the legacy `test_non_sales_pipeline_agent_is_noop` test asserted the
# SalesPipelineAgent name-gate that used to live in this callback. That gate
# was lifted out when the loop terminator became universal across every
# registered LlmAgent (see `shared_chat.stop_on_terminal_text`); the
# behavior coverage moved to `tests/python/test_stop_on_terminal_text.py`
# which exercises the truth table without any agent-name filter.
# ---------------------------------------------------------------------------
# Missing content / error_message paths — should not crash.
# ---------------------------------------------------------------------------
def test_no_content_no_parts_is_safe():
ctx = FakeCallbackContext()
response = SimpleNamespace(content=None, partial=False, error_message=None)
result = simple_after_model_modifier(ctx, response)
assert result is None
assert ctx._invocation_context.end_invocation is False
def test_error_message_only_is_safe():
ctx = FakeCallbackContext()
response = SimpleNamespace(
content=None, partial=False, error_message="something broke"
)
result = simple_after_model_modifier(ctx, response)
assert result is None
assert ctx._invocation_context.end_invocation is False
# ---------------------------------------------------------------------------
# Defensive fallback — callback_context missing _invocation_context must not crash.
# ---------------------------------------------------------------------------
def test_missing_invocation_context_does_not_crash():
ctx = SimpleNamespace(agent_name="SalesPipelineAgent") # no _invocation_context
response = _make_response(
role="model", has_text=True, has_function_call=False, partial=False
)
# The callback must handle the missing attribute gracefully (see the
# getattr(..., None) guard) and not raise.
result = simple_after_model_modifier(ctx, response)
assert result is None
# ---------------------------------------------------------------------------
# error_message branch must log at WARNING (CR round 4 finding #2).
#
# Gemini surfaces quota/safety-filter/context-overflow errors via
# llm_response.error_message. The prior implementation silently returned
# None, making these failures invisible in the server log. The fix logs at
# WARNING with the agent name before returning.
# ---------------------------------------------------------------------------
def test_error_message_logs_warning_with_agent_name(caplog):
"""When llm_response.error_message is set (no content), the callback
must emit a WARNING that includes the agent name and the error text."""
ctx = FakeCallbackContext(agent_name="SalesPipelineAgent")
response = SimpleNamespace(
content=None,
partial=False,
error_message="RESOURCE_EXHAUSTED: quota exceeded",
)
with caplog.at_level(logging.WARNING, logger="agents.main"):
result = simple_after_model_modifier(ctx, response)
assert result is None
warnings = [
rec
for rec in caplog.records
if rec.levelno == logging.WARNING
and "error_message" in rec.getMessage()
and "SalesPipelineAgent" in rec.getMessage()
and "RESOURCE_EXHAUSTED" in rec.getMessage()
]
assert warnings, (
f"expected WARNING log with agent name and error text, got: "
f"{[r.getMessage() for r in caplog.records]}"
)
# Note: the legacy `test_error_message_on_non_sales_agent_is_noop` test
# asserted that error_message warnings only fired for SalesPipelineAgent.
# With the unified `stop_on_terminal_text`, the error_message branch
# warns for EVERY agent (still a no-op for `end_invocation`), so the
# old assertion no longer holds. Error-message logging is now covered
# generically by `test_handles_error_message_branch_without_crashing`
# in `tests/python/test_stop_on_terminal_text.py`.
@@ -0,0 +1,194 @@
"""Regression test for agent-ID drift between frontend pages and the
registry / route.ts mount paths.
PR #4792 shipped with three drifts that all caused the same user-visible
"Application crashed" symptom:
- `/demos/hitl-in-chat` page passed `agent="hitl_in_chat"` (underscore)
while the backend mounted `/hitl-in-chat` (dash).
- `/demos/frontend-tools-async`: `agent="frontend_tools_async"` vs
backend `frontend-tools-async`.
- `/demos/prebuilt-popup`: `agent="prebuilt_popup"` vs backend
`prebuilt-popup`.
The frontend's `useAgent(<id>)` calls `runtime.getInfo()` to resolve the
agent map, and if the requested name isn't in there it throws the
`Agent '<id>' not found after runtime sync. Known agents: [...]` error
that crashes the React tree.
This test parses every demo page.tsx, harvests the agent IDs it claims
(via `agent=`, `agentId=`, or `agentId:` in `useAgent`/`useHumanInTheLoop`
calls), and asserts every harvested ID is actually mounted by
`registry.AGENT_REGISTRY`. Doesn't catch routing through dedicated routes
like /api/copilotkit-mcp-apps, but the main /api/copilotkit list in
route.ts is checked separately below.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2] # showcase/integrations/google-adk
DEMOS_DIR = REPO_ROOT / "src" / "app" / "demos"
API_DIR = REPO_ROOT / "src" / "app" / "api"
REGISTRY_PATH = REPO_ROOT / "src" / "agents" / "registry.py"
MAIN_ROUTE_PATH = API_DIR / "copilotkit" / "route.ts"
def _registry_mounts() -> set[str]:
"""Return the agent NAMES mounted by `agents/registry.py:AGENT_REGISTRY`.
Reads source directly so the test doesn't have to import google.adk —
keeps it runnable without the full agent dependency stack.
"""
text = REGISTRY_PATH.read_text(encoding="utf-8")
return set(
re.findall(r'^\s+["\']([a-zA-Z0-9_-]+)["\']\s*:\s*AgentSpec', text, re.M)
)
def _all_runtime_agent_ids() -> set[str]:
"""Return the union of every agent ID exposed by any route.ts.
Each demo can target a different runtime endpoint via `runtimeUrl`
(the main `/api/copilotkit` plus dedicated endpoints like
`/api/copilotkit-declarative-gen-ui`). Each route.ts declares its
own `agents: {...}` map (or `agentNames` array). We union them so
a demo's claimed agent ID just needs to appear in at least one
map. This matches what `useAgent` actually sees through the
runtime's `/info` endpoint, which is per-runtime.
"""
ids: set[str] = set()
# 1) main route.ts: const agentNames = [ "a", "b", ... ]
if MAIN_ROUTE_PATH.exists():
text = MAIN_ROUTE_PATH.read_text(encoding="utf-8")
m = re.search(r"const\s+agentNames\s*=\s*\[(.*?)\]", text, re.S)
if m:
ids.update(re.findall(r'["\']([a-zA-Z0-9_-]+)["\']', m.group(1)))
# 2) dedicated routes: harvest from BOTH the object form
# (`agents: { "name": new HttpAgent(...) }`) and the array form
# (`agents: ["name-a", "name-b"]` — used inside `openGenerativeUI`).
# The previous "balance braces" regex broke when an HttpAgent
# constructor wrapped its `url:` template across lines, swallowing
# the nested `{` and skipping the keys. Two simpler passes:
# (a) capture `agents: {` → next `},` block and pull out
# `"name":` keys.
# (b) capture `agents: [ ... ]` and pull out the quoted strings.
for route in API_DIR.rglob("route.ts"):
text = route.read_text(encoding="utf-8")
# (a) object-form agents map. Two declaration shapes:
# `agents: { ... }` (passed inline to `new CopilotRuntime(...)`)
# `const agents: Record<string, ...> = { ... }` (top-level)
for block in re.finditer(
r"agents(?:\s*:\s*[^=]+=\s*|\s*:\s*)\{(.+?)\}\s*[,;\n]",
text,
re.DOTALL,
):
ids.update(re.findall(r'["\']([a-zA-Z0-9_-]+)["\']\s*:', block.group(1)))
# (b) array-form agents list (e.g. openGenerativeUI.agents).
for block in re.finditer(r"agents\s*:\s*\[([^\]]*)\]", text):
ids.update(re.findall(r'["\']([a-zA-Z0-9_-]+)["\']', block.group(1)))
return ids
def _harvest_page_agents() -> dict[str, set[str]]:
"""Parse each demo page.tsx and harvest the agent IDs it claims."""
pattern = re.compile(r"""(?:agent|agentId)\s*[=:]\s*["']([a-zA-Z0-9_-]+)["']""")
out: dict[str, set[str]] = {}
for demo in sorted(DEMOS_DIR.iterdir()):
if not demo.is_dir() or demo.name.startswith("_"):
continue
page = demo / "page.tsx"
if not page.exists():
continue
ids = set(pattern.findall(page.read_text(encoding="utf-8")))
if ids:
out[demo.name] = ids
return out
def test_registry_mounts_at_least_one_agent():
mounts = _registry_mounts()
assert mounts, "registry.AGENT_REGISTRY appears empty — parser bug?"
def test_every_demo_page_agent_id_is_exposed_by_some_route():
"""For each demo's page.tsx, every agent ID it claims via
`agent=...` / `agentId=...` MUST appear in the agents map of at
least one route.ts (the main `/api/copilotkit` or a dedicated
runtime endpoint).
A mismatch here is the exact root cause of the
`useAgent: Agent '<id>' not found after runtime sync` crash. Demos
target different runtime endpoints via `runtimeUrl`, so we union
every route's agents map and verify membership against that — not
against the AGENT_REGISTRY directly (which is the BACKEND mount
table, not the frontend-visible agent map).
"""
exposed = _all_runtime_agent_ids()
per_page = _harvest_page_agents()
bad: list[tuple[str, str]] = []
for demo_name, ids in per_page.items():
for agent_id in ids:
if agent_id not in exposed:
bad.append((demo_name, agent_id))
assert not bad, (
"Agent-ID drift between demo page.tsx and runtime agent maps:\n"
+ "\n".join(
f" - /demos/{d} requests agent={a!r} but it's not exposed by "
f"any route.ts agents map (or main agentNames list)."
for d, a in bad
)
+ f"\nUnion of exposed IDs across all routes: {sorted(exposed)}"
)
def test_main_route_agent_map_aligns_with_registry():
"""`src/app/api/copilotkit/route.ts` has an `agentNames` list that
must be a subset of `registry.AGENT_REGISTRY` — every name in the
list is bound to an `HttpAgent({ url: \\`${AGENT_URL}/${name}\\` })`,
and the backend FastAPI will 404 on any name that isn't mounted.
"""
mounts = _registry_mounts()
text = MAIN_ROUTE_PATH.read_text(encoding="utf-8")
# The agentNames list looks like: `const agentNames = [ "a", "b", ... ];`
list_match = re.search(r"const\s+agentNames\s*=\s*\[(.*?)\]", text, re.S)
assert list_match, "Could not locate agentNames array in route.ts"
names = re.findall(r'["\']([a-zA-Z0-9_-]+)["\']', list_match.group(1))
assert names, "agentNames array parsed but contained zero string entries"
missing = [n for n in names if n not in mounts]
assert not missing, (
f"route.ts main agentNames list contains entries not mounted by "
f"registry: {missing}. Either add them to AGENT_REGISTRY or drop "
f"them from route.ts."
)
def test_known_renamed_demos_use_dashed_ids():
"""Pin the specific renames from PR #4792 so future refactors don't
silently revert them: hitl-in-chat, frontend-tools-async,
prebuilt-popup must use dash form on the frontend (matching backend
registry keys), not underscore."""
per_page = _harvest_page_agents()
expectations = {
"hitl-in-chat": "hitl-in-chat",
"frontend-tools-async": "frontend-tools-async",
"prebuilt-popup": "prebuilt-popup",
}
for demo, expected in expectations.items():
ids = per_page.get(demo, set())
assert expected in ids, (
f"/demos/{demo}/page.tsx no longer declares agent={expected!r}. "
f"Found IDs: {sorted(ids)}. The dash form is required to match "
f"the backend mount in registry.AGENT_REGISTRY."
)
# And the underscore variants MUST NOT be present.
underscore = expected.replace("-", "_")
assert underscore not in ids, (
f"/demos/{demo}/page.tsx has reverted to the underscore "
f"agent ID {underscore!r}. PR #4792's rename fix prevents "
f"the `useAgent: Agent not found` crash; this regressed it."
)
@@ -0,0 +1,222 @@
"""Unit tests for before_model_modifier.
Verifies that sales-todo state is serialized into the LLM system prompt and
that missing / malformed state degrades gracefully (no crash, no leaking
internal errors into the prompt).
"""
from __future__ import annotations
from types import SimpleNamespace
from google.genai import types
from agents.main import before_model_modifier
class FakeCallbackContext:
def __init__(
self,
*,
agent_name: str = "SalesPipelineAgent",
state: dict | None = None,
) -> None:
self.agent_name = agent_name
self.state = {} if state is None else state
def _make_request() -> SimpleNamespace:
"""Build a minimal LlmRequest-like object with an empty system_instruction config."""
config = SimpleNamespace(system_instruction=None)
return SimpleNamespace(config=config)
def _system_text(request) -> str:
"""Concatenate all system_instruction part texts for assertion."""
si = request.config.system_instruction
if si is None:
return ""
if not isinstance(si, types.Content):
return str(si)
return "".join((p.text or "") for p in (si.parts or []))
# ---------------------------------------------------------------------------
# Happy path: serialized todos appear in the prompt.
# ---------------------------------------------------------------------------
def test_todos_serialized_into_system_prompt():
todos = [
{"id": "1", "title": "Call Acme", "status": "open"},
{"id": "2", "title": "Send proposal", "status": "done"},
]
ctx = FakeCallbackContext(state={"todos": todos})
request = _make_request()
before_model_modifier(ctx, request)
text = _system_text(request)
assert "Call Acme" in text
assert "Send proposal" in text
assert "manage_sales_todos" in text
# ---------------------------------------------------------------------------
# Missing state: must not crash and must fall back to neutral placeholder.
# ---------------------------------------------------------------------------
def test_missing_todos_state_does_not_crash_and_uses_placeholder():
ctx = FakeCallbackContext(state={}) # no "todos" key at all
request = _make_request()
# Must not raise.
before_model_modifier(ctx, request)
text = _system_text(request)
assert "No sales todos yet" in text
def test_none_todos_state_uses_placeholder():
ctx = FakeCallbackContext(state={"todos": None})
request = _make_request()
before_model_modifier(ctx, request)
text = _system_text(request)
assert "No sales todos yet" in text
# ---------------------------------------------------------------------------
# Non-serializable todos: error must NOT leak into the prompt.
# ---------------------------------------------------------------------------
def test_non_serializable_todos_do_not_leak_error_into_prompt():
# Sets are not JSON-serializable → json.dumps raises TypeError.
ctx = FakeCallbackContext(state={"todos": {"bad": {1, 2, 3}}})
request = _make_request()
before_model_modifier(ctx, request)
text = _system_text(request)
# Error text must not bleed into the LLM prompt (that was the prior bug).
assert "Error serializing todos" not in text
# Neutral placeholder should be used instead.
assert "No sales todos yet" in text
# ---------------------------------------------------------------------------
# Different agent name: callback should be a no-op.
# ---------------------------------------------------------------------------
def test_non_sales_pipeline_agent_is_noop():
ctx = FakeCallbackContext(
agent_name="SomeOtherAgent", state={"todos": [{"id": "x"}]}
)
request = _make_request()
before_model_modifier(ctx, request)
# system_instruction should be untouched (still None).
assert request.config.system_instruction is None
# ---------------------------------------------------------------------------
# Prefix stacking regression (CR round 3 finding #2): calling the callback
# twice with the SAME request MUST NOT stack the prefix twice. The fix builds
# a fresh types.Content each call and assigns it, rather than mutating the
# previous parts[0].text in place.
# ---------------------------------------------------------------------------
def test_before_model_modifier_does_not_stack_prefix_on_repeated_calls():
todos = [{"id": "1", "title": "Call Acme", "status": "open"}]
ctx = FakeCallbackContext(state={"todos": todos})
request = _make_request()
# Call twice with the SAME request object.
before_model_modifier(ctx, request)
first_text = _system_text(request)
before_model_modifier(ctx, request)
second_text = _system_text(request)
# Prefix signature must appear exactly once after each call, not twice
# after the second.
prefix_signature = (
"You are a helpful sales assistant for managing a sales pipeline."
)
assert first_text.count(prefix_signature) == 1, (
f"expected prefix once on first call, got {first_text.count(prefix_signature)}: {first_text!r}"
)
assert second_text.count(prefix_signature) == 1, (
f"expected prefix once on second call (no stacking), got "
f"{second_text.count(prefix_signature)}: {second_text!r}"
)
def test_before_model_modifier_does_not_mutate_shared_original_instruction():
"""If system_instruction is a pre-existing shared Content, the callback
must NOT mutate it in place. Mutation would cause N requests sharing the
same base instruction to stack the prefix N times across requests."""
shared = types.Content(role="system", parts=[types.Part(text="BASE")])
ctx = FakeCallbackContext(state={"todos": []})
request = SimpleNamespace(config=SimpleNamespace(system_instruction=shared))
before_model_modifier(ctx, request)
# Shared instance must be untouched — callback should have assigned a
# fresh Content to request.config.system_instruction.
assert shared.parts[0].text == "BASE", (
f"shared system_instruction was mutated in place: {shared.parts[0].text!r}"
)
# And the request got a new Content, not the shared one.
assert request.config.system_instruction is not shared
# The new instruction still contains BASE appended to the prefix.
new_text = _system_text(request)
assert "BASE" in new_text
assert "You are a helpful sales assistant" in new_text
# ---------------------------------------------------------------------------
# Suffix preservation when prefix signature is present but end_marker is
# missing (mangled / drifted prefix) (CR round 4 finding #1).
#
# Prior behavior chopped `original_text[:sig_idx]` when the end_marker was
# absent, discarding ALL content after the signature — including legitimate
# user content that happened to follow a corrupted prefix. The fix leaves
# `original_text` as-is in that case; at worst we get one duplicated
# signature (single-shot, non-stacking) instead of data loss.
# ---------------------------------------------------------------------------
def test_before_model_modifier_preserves_suffix_when_end_marker_missing():
"""When system_instruction starts with the PREFIX_SIGNATURE but does NOT
contain the end_marker, the callback must NOT chop the user suffix.
Instead it should leave `original_text` untouched and prepend a fresh
prefix. Worst case is a single duplicated signature — that is explicitly
preferred over data loss."""
prefix_signature = (
"You are a helpful sales assistant for managing a sales pipeline."
)
# Build a mangled system_instruction: starts with the signature (so the
# `find(PREFIX_SIGNATURE)` branch fires), but DOES NOT contain the end
# marker sentence. A real-world user suffix follows that should survive.
user_suffix = "IMPORTANT: Always respond in French."
mangled_base = f"{prefix_signature} (but the end marker is missing!) {user_suffix}"
shared = types.Content(role="system", parts=[types.Part(text=mangled_base)])
ctx = FakeCallbackContext(state={"todos": []})
request = SimpleNamespace(config=SimpleNamespace(system_instruction=shared))
before_model_modifier(ctx, request)
new_text = _system_text(request)
# User suffix MUST survive — it was legitimate content after a
# corrupted prefix.
assert user_suffix in new_text, (
f"user suffix was dropped when end_marker was missing: {new_text!r}"
)
# A fresh prefix is prepended, so the signature appears at least once.
assert prefix_signature in new_text
@@ -0,0 +1,312 @@
"""Red→green tests for the google-adk backend CVDIAG boundary instrumentation.
Exercises the REAL emit surface — every assertion reads the actual
``CVDIAG {<json>}`` lines that ``_shared.cvdiag_bootstrap.emit_cvdiag`` writes
to stdout (captured via ``capsys``), driven through the real
``CvdiagBackendMiddleware`` and the real ``LlmCallScope`` / agent helpers. No
mocks of the emit path.
What's covered (spec §3 / §5 / §6):
* All 11 backend boundaries emit to stdout across the three request shapes
that collectively exercise them (happy streaming, aborted stream, raised
exception) for synthetic requests with ``CVDIAG_BACKEND_EMITTER=1`` (run at
DEBUG tier so the verbose+debug boundaries are permitted).
* PII scrub: a synthetic ``sk-test-12345`` in an exception message never
appears in the emitted ``backend.error.caught`` JSON.
* Heartbeat fires within ~12s of a slow-LLM simulation.
* Default-OFF: with the flag unset, NO CVDIAG backend line is emitted.
RED before instrumentation: ``agents._cvdiag_backend`` does not exist →
ImportError; the 11-boundary / heartbeat / scrub assertions cannot pass.
GREEN after: every boundary, the scrub, and the heartbeat assert true.
"""
from __future__ import annotations
import asyncio
import json
from typing import Dict, List
import pytest
from starlette.applications import Starlette
from starlette.responses import StreamingResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from agents._cvdiag_backend import (
CvdiagBackendMiddleware,
LlmCallScope,
_RequestCtx,
emit_agent_enter,
emit_agent_exit,
scrub,
)
# The 11 backend boundaries (spec §5).
ALL_BACKEND_BOUNDARIES = {
"backend.request.ingress",
"backend.agent.enter",
"backend.llm.call.start",
"backend.llm.call.heartbeat",
"backend.llm.call.response",
"backend.sse.first_byte",
"backend.sse.event",
"backend.sse.aborted",
"backend.agent.exit",
"backend.response.complete",
"backend.error.caught",
}
VALID_TEST_ID = "0190a9c0-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
def _parse_cvdiag_lines(captured: str) -> List[Dict]:
"""Extract every ``CVDIAG {<json>}`` envelope line from captured stdout."""
out: List[Dict] = []
for line in captured.splitlines():
if line.startswith("CVDIAG {"):
out.append(json.loads(line[len("CVDIAG ") :]))
return out
def _boundaries(envelopes: List[Dict]) -> set:
return {e["boundary"] for e in envelopes}
@pytest.fixture(autouse=True)
def _debug_tier(monkeypatch):
"""Run each test at DEBUG tier so verbose+debug boundaries are permitted.
``current_tier()`` is resolved once at bootstrap import; re-resolve it under
a non-production env with ``CVDIAG_DEBUG=1`` so the §6 matrix lets
``backend.sse.event`` (debug) and the verbose LLM boundaries through.
"""
import _shared.cvdiag_bootstrap as bootstrap
monkeypatch.setenv("SHOWCASE_ENV", "test")
monkeypatch.setenv("CVDIAG_DEBUG", "1")
bootstrap.setup({"SHOWCASE_ENV": "test", "CVDIAG_DEBUG": "1"})
yield
bootstrap.setup({"SHOWCASE_ENV": "test"})
def _make_client(*, raise_server_exceptions: bool = True) -> TestClient:
"""An app exposing three routes — happy stream, aborted stream, raise — each
wrapped by the CVDIAG middleware. The endpoints emit the agent/LLM
boundaries the middleware cannot observe, all keyed on the per-request ctx.
"""
async def happy_stream(request):
ctx = getattr(request.state, "cvdiag", None)
if ctx is not None:
emit_agent_enter(ctx, agent_name="showcase", model_id="gpt-4o-mini")
async def gen():
if ctx is not None:
async with LlmCallScope(
ctx, provider="openai", model="gpt-4o-mini", interval_s=0.02
):
await asyncio.sleep(0.05) # let the heartbeat tick once
yield b"data: hello\n\n"
yield b"data: world\n\n"
emit_agent_exit(ctx, terminal_outcome="ok", total_duration_ms=1)
else:
yield b"data: hello\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
async def raises(request):
raise RuntimeError("upstream rejected key sk-test-12345 Bearer abc.def.ghi")
app = Starlette(
routes=[
Route("/", happy_stream, methods=["POST"]),
Route("/boom", raises, methods=["POST"]),
]
)
app.add_middleware(CvdiagBackendMiddleware)
return TestClient(app, raise_server_exceptions=raise_server_exceptions)
async def _drive_abort() -> None:
"""Drive the CVDIAG middleware over an unbounded stream and disconnect.
Builds the middleware around an unbounded inner stream, calls ``dispatch``
to get the wrapped ``body_iterator``, reads one chunk, then ``aclose()``s it
— the deterministic equivalent of a client disconnecting mid-stream. This
raises ``GeneratorExit`` into the wrapper → ``backend.sse.aborted``.
"""
from starlette.requests import Request
async def unbounded():
i = 0
while True:
yield f"data: chunk-{i}\n\n".encode()
i += 1
inner_response = StreamingResponse(unbounded(), media_type="text/event-stream")
async def call_next(_request):
return inner_response
scope = {
"type": "http",
"method": "POST",
"path": "/",
"headers": [(b"x-aimock-context", b"google-adk")],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": b""}
mw = CvdiagBackendMiddleware(app=lambda *a: None)
request = Request(scope, receive)
wrapped = await mw.dispatch(request, call_next)
body = wrapped.body_iterator
await body.__anext__() # first chunk
await body.aclose() # client disconnect mid-stream
def test_all_eleven_backend_boundaries_emit(monkeypatch, capsys):
"""All 11 backend boundaries emit across the three request shapes.
The happy stream yields ingress / agent.enter / llm.* / sse.first_byte /
sse.event / agent.exit / response.complete; a disconnected stream yields
sse.aborted; the raising route yields error.caught. Their union is the full
eleven.
"""
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
client = _make_client(raise_server_exceptions=False)
headers = {"x-test-id": VALID_TEST_ID, "x-aimock-context": "google-adk"}
resp = client.post("/", headers=headers)
assert resp.status_code == 200
# Client-disconnect abort surface (→ backend.sse.aborted), driven directly
# because Starlette's sync TestClient cannot reliably tear a stream down
# mid-flight.
asyncio.run(_drive_abort())
client.post("/boom", headers=headers)
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
seen = _boundaries(envelopes)
missing = ALL_BACKEND_BOUNDARIES - seen
assert not missing, (
f"missing backend boundaries: {sorted(missing)}; saw {sorted(seen)}"
)
# Correlation: every backend envelope carries the slug. The header-bearing
# HTTP requests forward x-test-id verbatim; the directly driven abort
# request mints its own UUIDv7 (no inbound header). Assert the forwarded
# test_id appears on the header-bearing envelopes, and every minted id is a
# well-formed UUIDv7.
backend = [e for e in envelopes if e["layer"] == "backend"]
assert backend, "no backend-layer envelopes emitted"
assert all(e["slug"] == "google-adk" for e in backend)
forwarded = [e for e in backend if e["test_id"] == VALID_TEST_ID]
assert forwarded, "forwarded x-test-id never appeared on any backend envelope"
uuid7_re = __import__("re").compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
assert all(uuid7_re.match(e["test_id"]) for e in backend)
# Closed 9-key edge-header bag always present on a header-bearing ingress.
ingress = next(
e
for e in backend
if e["boundary"] == "backend.request.ingress" and e["test_id"] == VALID_TEST_ID
)
assert set(ingress["edge_headers"].keys()) == {
"cf-ray",
"cf-mitigated",
"cf-cache-status",
"x-railway-edge",
"x-railway-request-id",
"x-hikari-trace",
"retry-after",
"via",
"server",
}
def test_error_caught_scrubs_secret(monkeypatch, capsys):
"""A synthetic ``sk-test-12345`` in an exception never reaches the emitted
``backend.error.caught`` envelope."""
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
client = _make_client(raise_server_exceptions=False)
client.post("/boom", headers={"x-aimock-context": "google-adk"})
out = capsys.readouterr().out
envelopes = _parse_cvdiag_lines(out)
errs = [e for e in envelopes if e["boundary"] == "backend.error.caught"]
assert errs, "backend.error.caught not emitted"
err = errs[0]
assert err["metadata"]["exception_type"] == "RuntimeError"
blob = json.dumps(err)
assert "sk-test-12345" not in blob, "raw secret leaked into error envelope"
assert "Bearer abc" not in blob, "raw bearer token leaked into error envelope"
assert "[REDACTED]" in err["metadata"]["message_scrubbed"]
def test_scrub_helper_redacts_known_secret_shapes():
"""Unit-level: the scrub helper redacts bearer/sk-/pk-/userinfo shapes."""
assert "sk-test-12345" not in scrub("key sk-test-12345 here")
assert "sk-abcdefghijklmnopqrstuvwx" not in scrub("sk-abcdefghijklmnopqrstuvwx")
assert "Bearer secrettoken" not in scrub("auth Bearer secrettoken")
assert "pw" not in scrub("https://user:pw@host/path")
assert scrub(None) == ""
def test_heartbeat_fires_within_window(monkeypatch, capsys):
"""``backend.llm.call.heartbeat`` fires while a slow LLM call is outstanding.
Uses a short interval so the test is fast; the production interval is ~10s
and the spec requires a heartbeat within ~12s of a slow-LLM simulation —
proven here by the same code path firing within its interval.
"""
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
async def run():
ctx = _RequestCtx(test_id=VALID_TEST_ID, slug="google-adk", demo="default")
async with LlmCallScope(ctx, provider="openai", model="m", interval_s=0.05):
await asyncio.sleep(0.18) # ~3 heartbeat intervals
asyncio.run(run())
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
hb = [e for e in envelopes if e["boundary"] == "backend.llm.call.heartbeat"]
assert hb, "no heartbeat emitted during a slow LLM call"
assert all("elapsed_ms_since_start" in e["metadata"] for e in hb)
def test_sse_aborted_on_client_disconnect(monkeypatch, capsys):
"""Tearing the response stream down mid-flight emits ``backend.sse.aborted``
with a ``termination_kind`` and the bytes streamed before the abort."""
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
asyncio.run(_drive_abort())
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
aborts = [e for e in envelopes if e["boundary"] == "backend.sse.aborted"]
assert aborts, "backend.sse.aborted not emitted on client disconnect"
meta = aborts[0]["metadata"]
assert meta["termination_kind"] in {"rst", "timeout", "chunk_error"}
assert meta["bytes_before_abort"] > 0
# A disconnected stream must NOT also report a clean response.complete.
completes = [e for e in envelopes if e["boundary"] == "backend.response.complete"]
assert not completes, "clean response.complete emitted for an aborted stream"
def test_disabled_by_default_emits_nothing(monkeypatch, capsys):
"""With ``CVDIAG_BACKEND_EMITTER`` unset, NO backend CVDIAG line is emitted."""
monkeypatch.delenv("CVDIAG_BACKEND_EMITTER", raising=False)
client = _make_client()
client.post("/", headers={"x-aimock-context": "google-adk"})
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
backend = [e for e in envelopes if e["layer"] == "backend"]
assert backend == [], f"emitter fired while disabled: {backend}"
@@ -0,0 +1,126 @@
"""Subprocess tests for entrypoint.sh environment-variable guards.
We exercise only the GOOGLE_API_KEY branch at the top of the script. To avoid
actually starting uvicorn / next, we short-circuit the shell by arranging for
the script to exit before reaching the background-process section:
- When REQUIRE_GOOGLE_API_KEY=1 and GOOGLE_API_KEY is empty, the script must
exit 1 IMMEDIATELY, before starting any subprocess. We assert exit=1 and
the FATAL message on stderr.
- When REQUIRE_GOOGLE_API_KEY is unset / 0 and GOOGLE_API_KEY is empty, the
script must WARN and continue. We can't let it continue (it would try to
launch the full stack), so we intercept by stubbing `python` and `npx` via
a prepended PATH so they exit 0 immediately — the script then runs `wait
-n` on already-exited children and exits 0 with the WARN message logged.
This package is Gemini end-to-end (primary LlmAgent + generate_a2ui both use
google.genai), so the entrypoint guard was migrated from OPENAI_API_KEY to
GOOGLE_API_KEY.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
_PKG_ROOT = Path(__file__).resolve().parents[2]
_ENTRYPOINT = _PKG_ROOT / "entrypoint.sh"
def _run_entrypoint(
env: dict[str, str] | None = None,
*,
stub_subprocesses: bool = False,
tmp_path: Path | None = None,
) -> subprocess.CompletedProcess:
"""Run entrypoint.sh in a subprocess with a controlled environment.
When `stub_subprocesses=True`, we prepend `tmp_path` to PATH with stub
`python` and `npx` scripts that `exit 0` immediately, so the entrypoint's
background children terminate instantly and we can observe the full
flow without actually launching uvicorn / Next.
"""
clean_env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", "/tmp"),
}
if env:
clean_env.update(env)
if stub_subprocesses:
assert tmp_path is not None, "tmp_path required when stub_subprocesses=True"
stub_dir = tmp_path / "stubs"
stub_dir.mkdir(exist_ok=True)
for name in ("python", "npx"):
stub = stub_dir / name
stub.write_text("#!/bin/sh\nexit 0\n")
stub.chmod(0o755)
clean_env["PATH"] = f"{stub_dir}:{clean_env['PATH']}"
return subprocess.run(
["/bin/bash", str(_ENTRYPOINT)],
env=clean_env,
capture_output=True,
text=True,
timeout=15,
)
@pytest.mark.skipif(not _ENTRYPOINT.exists(), reason="entrypoint.sh not found")
def test_require_google_api_key_fails_fast_when_missing():
"""REQUIRE_GOOGLE_API_KEY=1 + missing GOOGLE_API_KEY → exit 1, FATAL log."""
result = _run_entrypoint(
env={"REQUIRE_GOOGLE_API_KEY": "1"},
)
assert result.returncode == 1, (
f"expected exit 1, got {result.returncode}. stderr={result.stderr!r}"
)
assert "FATAL" in result.stderr
assert "GOOGLE_API_KEY not set" in result.stderr
assert "REQUIRE_GOOGLE_API_KEY=1" in result.stderr
@pytest.mark.skipif(not _ENTRYPOINT.exists(), reason="entrypoint.sh not found")
def test_default_missing_key_warns_but_does_not_fail_fast(tmp_path):
"""Without REQUIRE_GOOGLE_API_KEY, missing key only WARNs — script
continues past the guard. With stubbed python/npx, the entrypoint
completes and exits 0 (both children exited cleanly)."""
result = _run_entrypoint(
env={}, # no REQUIRE_GOOGLE_API_KEY, no GOOGLE_API_KEY
stub_subprocesses=True,
tmp_path=tmp_path,
)
# Script must NOT fail fast on the guard. It continues; both stubbed
# children exit 0 so the overall script exits 0.
assert "FATAL" not in result.stderr, (
f"unexpected fail-fast when REQUIRE_GOOGLE_API_KEY is unset: {result.stderr!r}"
)
assert "WARN: GOOGLE_API_KEY not set" in result.stderr
@pytest.mark.skipif(not _ENTRYPOINT.exists(), reason="entrypoint.sh not found")
def test_require_google_api_key_zero_also_warns(tmp_path):
"""REQUIRE_GOOGLE_API_KEY=0 is equivalent to unset — WARN only."""
result = _run_entrypoint(
env={"REQUIRE_GOOGLE_API_KEY": "0"},
stub_subprocesses=True,
tmp_path=tmp_path,
)
assert "FATAL" not in result.stderr
assert "WARN: GOOGLE_API_KEY not set" in result.stderr
@pytest.mark.skipif(not _ENTRYPOINT.exists(), reason="entrypoint.sh not found")
def test_present_key_does_not_warn_or_fail(tmp_path):
"""GOOGLE_API_KEY present → no WARN, no FATAL, regardless of REQUIRE flag."""
result = _run_entrypoint(
env={"GOOGLE_API_KEY": "test-dummy", "REQUIRE_GOOGLE_API_KEY": "1"},
stub_subprocesses=True,
tmp_path=tmp_path,
)
assert "FATAL" not in result.stderr
assert "WARN: GOOGLE_API_KEY not set" not in result.stderr
@@ -0,0 +1,151 @@
"""Regression tests for the universal Gemini loop-stopper.
`stop_on_terminal_text` (in `agents/shared_chat.py`) flips
`callback_context._invocation_context.end_invocation = True` iff the final
non-partial model response contains TEXT and NO function_call. Wired into
every registered LlmAgent in `agents/registry.py` via the `build_*`
factories in shared_chat plus a manual `after_model_callback=` on every
dedicated agent (tool_rendering, gen_ui_*, hitl_*, a2ui_*, etc.).
Without this callback Gemini 3.1 Flash-Lite re-issues the same tool indefinitely
after each successful tool result; PR #4792's "all tools repeat infinitely"
symptom was the visible manifestation. Pinning the truth table here prevents
silent regressions when the callback or its wiring is refactored.
Mirrors the parallel `test_after_model_modifier.py` which covers the
SalesPipelineAgent-scoped legacy `simple_after_model_modifier` — the new
test asserts the SAME behavior but for the demos that actually ship.
"""
from __future__ import annotations
from types import SimpleNamespace
from agents.shared_chat import stop_on_terminal_text
class FakeInvocationContext:
def __init__(self) -> None:
self.end_invocation = False
class FakeCallbackContext:
def __init__(self, agent_name: str = "ToolRenderingAgent") -> None:
self.agent_name = agent_name
self._invocation_context = FakeInvocationContext()
def _make_part(*, text: str | None = None, function_call: object | None = None):
part = SimpleNamespace()
part.text = text
part.function_call = function_call
return part
def _make_response(
*,
parts=None,
partial: bool = False,
role: str = "model",
finish_reason: object = "STOP",
):
"""Build a fake LlmResponse.
`finish_reason` defaults to "STOP" because that's the real terminal
response shape — `stop_on_terminal_text` gates termination on it to
avoid premature termination on Gemini thinking-mode chunks that arrive
non-partial with `finish_reason=None` (the text-only chunk that precedes
the function-call chunk).
"""
return SimpleNamespace(
content=SimpleNamespace(role=role, parts=list(parts or [])),
partial=partial,
error_message=None,
finish_reason=finish_reason,
turn_complete=None,
)
def test_terminates_on_final_text_only_model_response():
"""The happy path: final response is text without a function_call → stop."""
ctx = FakeCallbackContext()
resp = _make_response(parts=[_make_part(text="Tokyo is sunny, 68°F.")])
stop_on_terminal_text(ctx, resp)
assert ctx._invocation_context.end_invocation is True
def test_does_not_terminate_on_mixed_text_and_function_call():
"""Gemini sometimes emits text + function_call together; tool must still run."""
ctx = FakeCallbackContext()
resp = _make_response(
parts=[
_make_part(text="Looking up the weather in Tokyo for you."),
_make_part(function_call=SimpleNamespace(name="get_weather")),
]
)
stop_on_terminal_text(ctx, resp)
assert ctx._invocation_context.end_invocation is False
def test_does_not_terminate_on_pure_function_call():
"""No text, only function_call → keep going."""
ctx = FakeCallbackContext()
resp = _make_response(
parts=[_make_part(function_call=SimpleNamespace(name="get_weather"))]
)
stop_on_terminal_text(ctx, resp)
assert ctx._invocation_context.end_invocation is False
def test_does_not_terminate_on_partial_stream_chunk():
"""Belt-and-suspenders with ADK_DISABLE_PROGRESSIVE_SSE_STREAMING — never
end on a partial event even if it happens to contain text-only parts."""
ctx = FakeCallbackContext()
resp = _make_response(
parts=[_make_part(text="Partial fragment...")],
partial=True,
)
stop_on_terminal_text(ctx, resp)
assert ctx._invocation_context.end_invocation is False
def test_does_not_terminate_on_non_model_role():
"""Only terminate on `role == "model"` responses. Defensive guard against
user or system role responses sneaking into the callback."""
ctx = FakeCallbackContext()
resp = _make_response(parts=[_make_part(text="hello")], role="user")
stop_on_terminal_text(ctx, resp)
assert ctx._invocation_context.end_invocation is False
def test_handles_missing_invocation_context_gracefully():
"""ADK's `_invocation_context` is private — log-and-degrade instead of
crashing the callback when it disappears (would stall the whole request)."""
ctx = SimpleNamespace(agent_name="ToolRenderingAgent", _invocation_context=None)
resp = _make_response(parts=[_make_part(text="terminal text")])
# Must not raise.
stop_on_terminal_text(ctx, resp)
# No invocation_context to flip — nothing to assert beyond no-raise.
def test_handles_invocation_context_without_end_invocation_attr():
"""If `_invocation_context` exists but doesn't expose `end_invocation`
(ADK shape drift), the callback logs and continues without raising."""
bad_ctx = object() # no end_invocation attribute
ctx = SimpleNamespace(agent_name="ToolRenderingAgent", _invocation_context=bad_ctx)
resp = _make_response(parts=[_make_part(text="terminal text")])
# Must not raise — the AttributeError on setattr is swallowed and logged.
stop_on_terminal_text(ctx, resp)
def test_handles_error_message_branch_without_crashing():
"""When Gemini surfaces error_message (quota, safety, context-overflow)
the callback must not flip end_invocation and must not raise."""
ctx = FakeCallbackContext()
resp = SimpleNamespace(
content=SimpleNamespace(role="model", parts=[]),
partial=False,
error_message="quota exhausted",
)
stop_on_terminal_text(ctx, resp)
assert ctx._invocation_context.end_invocation is False