Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI

# The only workflow here was docker-publish.yml, so nothing ran pytest on a PR —
# including the PR that fixed EVO-2181. The suite is pure unit tests (no DB, no
# Redis), it just needs the runtime deps because src/services/__init__ imports
# eagerly.

on:
pull_request:
push:
branches: [develop, main]

jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip

- name: Install dependencies
run: pip install -r requirements.txt

# tests/unit/test_exception_handlers.py is excluded, not skipped silently:
# it imports generic_exception_handler, which no longer exists in
# src/core/exception_handlers.py (removed in e02a229) and is no longer
# registered in src/main.py either. That is an ImportError at *collection*
# time, so leaving it in makes pytest exit before running anything. The
# missing handler is a separate regression from EVO-972 (it kept internal
# class names out of 500 bodies) and needs its own card — drop this
# exclusion when it is restored.
- name: Unit tests
run: pytest tests/unit -q --ignore=tests/unit/test_exception_handlers.py
11 changes: 8 additions & 3 deletions src/services/adk/runners/runner_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,14 @@ def _inline_skip_reason(
the rest of the message -- text and readable media -- still gets an
answer. Forwarding it instead costs the whole turn.
"""
# "audio/webm;codecs=opus" -> "audio/webm". The Blob keeps the content
# type verbatim; only the check normalizes.
mime_type = (content_type or "").split(";")[0].strip().lower()
# Checked verbatim, exactly as ADK will see it on the Blob. Normalizing
# first (lowercasing, dropping ";codecs=opus") would answer a question
# nobody asks downstream: _get_content matches the raw mime_type, with a
# case-sensitive startswith and an exact-match set. So "IMAGE/PNG" and
# "application/pdf; charset=binary" would clear a normalized check and
# then raise ValueError inside ADK -- a 500 that costs the whole turn,
# which is the failure this guard exists to prevent.
mime_type = content_type or ""
if not (
mime_type.startswith(self._MODEL_READABLE_MIME_PREFIXES)
or mime_type in self._MODEL_READABLE_MIME_TYPES
Expand Down
37 changes: 36 additions & 1 deletion tests/unit/test_media_file_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import pytest
from google.adk.models.lite_llm import _get_content
from google.genai.types import Blob, Part

from src.schemas.chat import FileData
from src.services.adk.runners.runner_utils import RunnerUtils
Expand Down Expand Up @@ -131,10 +132,44 @@ def test_mime_parameters_do_not_break_the_check():
parts, _ = _run(
_utils().process_files([_file("v.webm", "audio/webm;codecs=opus")], _artifacts(), "a", "e", "s")
)
# Normalized for the check, verbatim on the Blob.
# The prefix still matches with the parameter attached, and ADK reads the
# verbatim value off the Blob, so the two agree.
assert [p.inline_data.mime_type for p in parts] == ["audio/webm;codecs=opus"]


# The guard must judge the mime exactly as ADK will, because ADK is what raises.
# A check that normalized first would pass these through and turn them into a 500
# in _get_content -- losing the caption along with the file.
@pytest.mark.parametrize(
"content_type",
[
"IMAGE/PNG", # case-sensitive startswith in _get_content
"Image/png",
"application/pdf; charset=binary", # exact-match set in _get_content
"application/json;charset=utf-8",
],
)
def test_mime_adk_would_reject_is_skipped_not_forwarded(content_type):
artifacts = _artifacts()
parts, _ = _run(_utils().process_files([_file("f.bin", content_type)], artifacts, "a", "e", "s"))
assert parts == []
artifacts.save_artifact.assert_awaited_once() # still kept for reference


def test_every_skipped_mime_would_really_have_broken_adk():
"""The mirror is only worth having if it matches ADK's real behaviour.

Feeds ADK exactly what the guard rejects and asserts it raises, so a future
ADK bump that starts accepting these shows up as a failure here instead of as
a guard that silently drops media the model could have read.
"""
utils = _utils()
for content_type in ["IMAGE/PNG", "application/pdf; charset=binary", DOCX]:
part = Part(inline_data=Blob(mime_type=content_type, data=b"bytes"))
with pytest.raises(ValueError):
_get_content([part])


def test_image_survives_an_artifact_store_failure():
artifacts = MagicMock()
artifacts.save_artifact = AsyncMock(side_effect=RuntimeError("artifact store down"))
Expand Down
Loading