feat(photo-editor): NL editor planner, image ops, email/gallery server

Server-side photo editor: natural-language editor agent route
(/api/editor/agent), photo_editor tool family + style guide, image-op
provider proxy with progress, email server hardening, gallery/hwfit
routes, and graceful shutdown.

Source: vendor/photocraft (ad1cff44) editor-server files, merged onto
upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/B_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-15 11:57:00 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit eb940f24a3
46 changed files with 9662 additions and 725 deletions
+36
View File
@@ -0,0 +1,36 @@
"""Tests for src/activity.py — in-flight heavy-image-op counters (safe restart)."""
from src import activity
def test_image_op_counter_increments_and_decrements():
assert activity.heavy_image_ops_active() == 0
activity.begin_image_op("a")
activity.begin_image_op("a")
assert activity.heavy_image_ops_active() == 2
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 1
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 0
# never goes negative
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 0
def test_image_op_context_manager_and_busy():
assert activity.busy() is False
with activity.image_op("/api/image/inpaint"):
assert activity.busy() is True
snap = activity.activity()
assert snap["image_ops"] == 1
assert snap["image_ops_by_name"].get("/api/image/inpaint") == 1
assert activity.busy() is False
assert activity.activity()["image_ops"] == 0
def test_image_op_released_on_exception():
try:
with activity.image_op("boom"):
raise RuntimeError("x")
except RuntimeError:
pass
assert activity.heavy_image_ops_active() == 0
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -1,10 +1,9 @@
"""Repository asset ownership guards for issues #1335 and #6175.
Public Markdown and landing-page media belong in website/, while shared
optional README/packaging imagery belongs in assets/branding/. Retained images
in either managed root must be referenced by tracked text, and every retained
website video must be referenced by the site's entry point. Publication can
operate with no imagery or videos.
README/packaging imagery belongs in assets/branding/. Images in either managed
root must be referenced by tracked text, and every tracked website video must
be referenced by the site's entry point.
"""
import re
import subprocess
@@ -42,8 +41,7 @@ def _tracked(*paths_under):
return None
if out.returncode != 0:
return None
return [REPO / line for line in out.stdout.splitlines()
if line.strip() and (REPO / line).is_file()]
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
def test_no_orphan_documentation_or_branding_images():
@@ -51,6 +49,9 @@ def test_no_orphan_documentation_or_branding_images():
if managed_files is None:
pytest.skip("not a git checkout")
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
assert any("assets/branding" in p.as_posix() for p in managed_images), (
"expected assets/branding/ to contain the shared project imagery"
)
# All tracked text we might reference an image from.
all_tracked = _tracked(".") or []
@@ -93,6 +94,7 @@ def test_pages_site_owns_its_entrypoint_and_media():
assert text.startswith("---\nlayout: default\n---\n"), guide
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
assert website_videos, "expected website/ to contain the landing-page videos"
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
unreferenced = [
+26 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import json
from types import SimpleNamespace
import pytest
from PIL import Image
from src.tools.image import do_edit_image
@@ -122,7 +123,8 @@ def test_edit_image_rejects_actions_without_complete_input_contract():
assert "Use prompt, upscale or rembg" in result["error"]
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path):
@pytest.mark.parametrize('incognito', [False, True])
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path, incognito):
source_path = tmp_path / 'source.png'
Image.new('RGB', (3, 2), 'red').save(source_path)
original = source_path.read_bytes()
@@ -135,9 +137,9 @@ def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, t
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
from pathlib import Path
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice'))
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice', incognito=incognito))
assert result['image_id'] == 'edited'
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto'})]
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto', 'incognito': incognito})]
assert source_path.read_bytes() == original
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
assert denied['error'] == 'Image not found'
@@ -165,7 +167,7 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto'})]
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto', 'incognito': False})]
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
assert len(calls) == 1
@@ -173,3 +175,23 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
disabled = asyncio.run(do_edit_image(args, owner='alice'))
assert 'disabled' in disabled['error']
assert len(calls) == 1
def test_private_upscale_does_not_save_image_or_gallery_record(monkeypatch, tmp_path):
source_path = tmp_path / 'source.png'
Image.new('RGB', (3, 2), 'red').save(source_path)
original = source_path.read_bytes()
db = _Db(_source(source_path.name))
monkeypatch.setattr('core.database.SessionLocal', lambda: db)
monkeypatch.setattr('src.constants.GENERATED_IMAGES_DIR', str(tmp_path))
result = asyncio.run(do_edit_image(
'{"image_id":"source-id","action":"upscale","scale":2}',
owner='alice', incognito=True,
))
assert result['exit_code'] == 0
assert result['image_url'].startswith('data:image/png;base64,')
assert not result['image_id']
assert db.added == []
assert not db.committed
assert list(tmp_path.iterdir()) == [source_path]
assert source_path.read_bytes() == original
@@ -0,0 +1,71 @@
"""Server health audit fix: the local editor VLM returned 400s when asked for a
model id it does not serve (the client default 'Qwen3.5-9B-MLX-8bit', or the
stale caption model 'Qwen/Qwen3-VL-30B'). ``_resolve_model`` maps the requested
id to one the base actually serves."""
import asyncio
from src import editor_agent_model as eam
def _cache(base, ids):
eam._MODELS_CACHE[base] = set(ids)
def test_resolve_model_keeps_a_served_request():
base = "http://x:1/v1"
_cache(base, ["Qwen/Qwen3.5-9B", "other"])
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3.5-9B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
def test_resolve_model_falls_back_when_requested_is_not_served():
base = "http://x:2/v1"
_cache(base, ["Qwen/Qwen3.5-9B"])
# e.g. the client's MLX build name, or a stale caption model id.
assert asyncio.run(eam._resolve_model(base, "Qwen3.5-9B-MLX-8bit", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3-VL-30B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
def test_resolve_model_picks_a_served_id_when_fallback_absent():
base = "http://x:3/v1"
_cache(base, ["B", "A"])
assert asyncio.run(eam._resolve_model(base, "nope", "also-nope")) == "A"
def test_resolve_model_keeps_request_when_served_list_unknown():
base = "http://x:4/v1"
_cache(base, [])
# Server unreachable / no models listed -> don't rewrite the request.
assert asyncio.run(eam._resolve_model(base, "some/model", "fallback")) == "some/model"
def test_self_check_resolves_the_model_before_calling(monkeypatch):
base = eam.local_bases()[0]
_cache(base, ["Qwen/Qwen3.5-9B"])
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
seen["model"] = model
return "DONE"
monkeypatch.setattr(eam, "_post_chat", fake_post)
out = asyncio.run(eam.self_check(
"data:image/png;base64,AAAA", "data:image/png;base64,BBBB",
"make it brighter", model="Qwen3.5-9B-MLX-8bit"))
assert seen["model"] == "Qwen/Qwen3.5-9B"
assert out["done"] is True
def test_caption_resolves_the_caption_model(monkeypatch):
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
seen["base"] = b
seen["model"] = model
return "a photo"
monkeypatch.setattr(eam, "_post_chat", fake_post)
import src.editor_agent_model as m
_cache(m.caption_base(), ["Qwen/Qwen3.5-9B"])
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
assert txt == "a photo"
assert seen["model"] == "Qwen/Qwen3.5-9B"
+171
View File
@@ -0,0 +1,171 @@
"""Planner routing regression suite (HARNESS-owned).
Turns PHOTOSHOP's bench prompt families (vague, restyle, bg-remove/scene,
tone/auto, generative, structural) into assertions on the ROUTE CLASS the
server planner picks, so a future planner change cannot silently re-break
routing. The classes are:
ask_user -> the planner is unsure; it must ask with 2-3 choices and
apply NO operations (never a silent tone default)
deterministic -> a deterministic editor op / recipe (adjustments, filters,
crop, text, rotate, fill, recolour ...)
ai_edit_image -> a generative image-model edit (content add/remove/replace,
restyle, look)
generate_image -> text-to-image for a brand-new image
These use the pure routing helpers (``_plan_special`` / ``_generative_kind`` /
``_fallback_plan``), so no model or network call is made.
"""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import editor_agent_routes as eaar
_STATE = {"width": 900, "height": 600}
_PROVIDER = True
@pytest.fixture(autouse=True)
def _reset_planner():
eaar.set_editor_planner(None)
yield
eaar.set_editor_planner(None)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return TestClient(app)
def _classify(prompt, provider=_PROVIDER):
"""Return (route_class, ops) for a prompt using the deterministic helpers."""
special = eaar._plan_special(prompt, _STATE, provider)
if special:
steps = list(special[0])
else:
fallback = eaar._fallback_plan(prompt)
if fallback.get("ask_user"):
return "ask_user", []
steps = list(fallback.get("steps") or [])
ops = [str(s.get("op") or "") for s in steps]
if "generate_image" in ops:
return "generate_image", ops
if "ai_edit_image" in ops:
return "ai_edit_image", ops
return "deterministic", ops
# (prompt, expected route class)
BENCH_ROUTING = [
# vague -> ask (never a silent tone tweak)
("do the thing", "ask_user"),
("give it some oomph", "ask_user"),
("make it better", "ask_user"),
# whole-image restyle -> model edit
("make it look like an oil painting", "ai_edit_image"),
("turn it into an anime", "ai_edit_image"),
("make it a comic book illustration", "ai_edit_image"),
("make it look like cyberpunk", "ai_edit_image"),
# background / scene replacement -> model edit
("put her on a beach", "ai_edit_image"),
("make the background nicer", "ai_edit_image"),
("make it snowy", "ai_edit_image"),
# generative content add / remove -> model edit
("add a full moon in the sky", "ai_edit_image"),
("remove the distractions", "ai_edit_image"),
("remove the person on the left", "ai_edit_image"),
# brand-new image -> text-to-image
("create a new image of a full moon", "generate_image"),
("generate an image of a sunset", "generate_image"),
("make a logo", "generate_image"),
# tone / auto / look / structural -> deterministic
("make it black and white", "deterministic"),
("make it pop", "deterministic"),
("make it look professional", "deterministic"),
("make only the red car blue", "deterministic"),
("make a 1080x1350 Instagram post from this", "deterministic"),
("add a title TOKYO with a drop shadow", "deterministic"),
("extend the canvas to 16:9", "deterministic"),
("save as PSD with layers", "deterministic"),
("fill the white area with red", "deterministic"),
("rotate the whole image 90 degrees", "deterministic"),
]
@pytest.mark.parametrize("prompt,expected", BENCH_ROUTING)
def test_bench_prompts_route_to_expected_class(prompt, expected):
route_class, ops = _classify(prompt)
assert route_class == expected, f"{prompt!r} -> {route_class} {ops} (want {expected})"
def test_ask_plans_offer_two_to_three_choices():
for prompt in ("do the thing", "give it some oomph", "make it better"):
plan = eaar._fallback_plan(prompt)
ask = plan.get("ask_user")
assert isinstance(ask, dict), prompt
opts = ask.get("options") or []
assert 2 <= len(opts) <= 3, (prompt, opts)
assert plan.get("steps") == []
def test_object_removal_uses_selection_plus_model_edit():
_cls, ops = _classify("remove the person on the left")
assert "ai_edit_image" in ops
assert any(op.startswith("selection.") for op in ops), ops
def test_recolour_never_uses_a_flat_fill_or_a_global_tint():
# Every recolour selects the object by description and either shifts the
# masked hue or asks the image model for the masked region — never a flat
# selection.fill and never a whole-image adjustments.apply.
for prompt in ("make only the red car blue", "make the red car white", "make the flowers bright pink"):
_cls, ops = _classify(prompt)
assert "selection.samText" in ops, (prompt, ops)
assert "selection.fill" not in ops, (prompt, ops)
assert "adjustments.apply" not in ops, (prompt, ops)
def test_chromatic_recolour_is_a_deterministic_masked_hue_shift():
cls, ops = _classify("make only the red car blue")
assert cls == "deterministic", ops
assert ops == ["selection.samText", "adjustments.applyMasked"], ops
def test_new_image_request_from_scratch_vs_edit_of_current_photo():
# A photo edit ("make it a <style>") must NOT be treated as a new image.
assert eaar._generative_kind("make it a comic book illustration") == "restyle"
assert eaar._is_new_image_request("make it a comic book illustration") is False
# A from-scratch generation still is.
assert eaar._is_new_image_request("create an illustration of a cat") is True
assert eaar._generative_kind("create an illustration of a cat") == "add"
def test_plan_route_returns_deterministic_recolour(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "make only the red car blue", "state": _STATE})
assert r.status_code == 200
data = r.json()
assert data["planner"] == "deterministic"
assert [s["op"] for s in data["steps"]] == ["selection.samText", "adjustments.applyMasked"]
def test_plan_route_asks_for_a_vague_prompt(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "do the thing", "state": _STATE})
assert r.status_code == 200
data = r.json()
assert data.get("ask_user"), data
assert data.get("steps") == []
def test_plan_route_routes_restyle_to_model_edit(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "turn it into an anime", "state": _STATE})
assert r.status_code == 200
ops = [s["op"] for s in r.json()["steps"]]
assert "ai_edit_image" in ops, ops
@@ -11,6 +11,7 @@ from src.clean_agent_preview import (
from src.turn_contract import (
editor_request_instructions, requested_capabilities, selected_tools_for_request,
preserve_bound_editor_selected_tools, resolve_turn_contract,
targets_bound_editor_request,
)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
@@ -72,6 +73,16 @@ def test_direct_edits_share_the_capability_router(prompt):
assert not active_editor_suggestion_request(doc, prompt)
@pytest.mark.parametrize('prompt', [
'ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist trivs sa bra har',
'Go more into how we are thinking about the future.',
])
def test_implicit_revision_followups_route_to_active_document_only(prompt):
assert requested_capabilities(prompt, active_document=True) == {'documents'}
assert requested_capabilities(prompt, active_document=False) == frozenset()
assert targets_bound_editor_request(prompt)
def test_source_review_still_requires_tool_completion_after_research():
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
+6 -6
View File
@@ -26,7 +26,7 @@ class _FakeSock:
class _FakeIMAP:
calls = []
def __init__(self, host, port, timeout=None, **_policy):
def __init__(self, host, port, timeout=None):
self.host = host
self.port = port
self.timeout = timeout
@@ -60,8 +60,8 @@ def test_open_imap_connection_uses_shared_timeout_for_implicit_ssl(monkeypatch):
import routes.email_helpers as helpers
_FakeIMAP.calls = []
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
@@ -75,8 +75,8 @@ def test_open_imap_connection_supports_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeIMAP.calls = []
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
_open_imap_connection("imap.local", 143, starttls=True)
@@ -99,7 +99,7 @@ async def test_account_config_uses_shared_imap_timeout(monkeypatch):
def logout(self):
captured["logout"] = True
def fake_open(host, port, *, starttls, timeout, **_policy):
def fake_open(host, port, *, starttls, timeout):
captured["open"] = (host, port, starttls, timeout)
return _Conn()
@@ -0,0 +1,79 @@
"""Email list stale-while-revalidate (SWR) regression tests.
A plain list view must never block ~2-5 s on IMAP while we already hold a
recent snapshot or a durable index: it should return the snapshot immediately
(with ``sync.stale``) and refresh in the background. Blocking IMAP only remains
for a genuine cold start (no snapshot AND no indexed rows).
"""
import asyncio
import pathlib
from routes.email import email_routes as er
SRC = pathlib.Path(er.__file__).read_text(encoding="utf-8")
def _list_endpoint():
router = er.setup_email_routes()
for route in router.routes:
if getattr(route, "name", "") == "list_emails":
return route.endpoint
raise AssertionError("list_emails route not found")
def _call(endpoint, **over):
kwargs = dict(
folder="INBOX", limit=50, offset=0, filter="all", from_addr=None,
account_id=None, has_attachments=0, date_from=None, date_to=None,
cached_only=0, cache_bust=None, refresh=0, owner="pewds",
)
kwargs.update(over)
return asyncio.run(endpoint(**kwargs))
def test_list_returns_index_snapshot_with_a_stale_marker_without_blocking_on_imap(monkeypatch):
endpoint = _list_endpoint()
monkeypatch.setattr(
er, "_email_index_list",
lambda owner, account_id, folder, filter_, limit, offset, has_att: (
[{"uid": "11", "subject": "Indexed", "is_read": False}], 1, "2026-10-11T00:00:00Z",
),
)
# The synchronous path must never open IMAP; if it did the request would
# raise instead of returning the index snapshot immediately.
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("SWR must not open IMAP when an index snapshot exists")))
result = _call(endpoint, cache_bust="123")
assert result["total"] == 1
assert [e["uid"] for e in result["emails"]] == ["11"]
assert result["sync"]["stale"] is True
assert result["sync"]["source"] in ("index", "cache_stale")
def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatch):
# With neither a snapshot nor an index, SWR must NOT fire: the request
# falls through to the real (blocking) path, so the response is not a
# stale-marked snapshot.
endpoint = _list_endpoint()
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("offline test")))
try:
result = _call(endpoint)
except Exception:
return # the cold path legitimately attempted real IMAP
assert isinstance(result, dict)
assert not (result.get("sync") or {}).get("stale")
def test_swr_contract_is_wired_into_the_list_path():
assert "_LIST_STALE" in SRC
assert "def _list_stale_put" in SRC
assert "def _schedule_list_refresh" in SRC
assert 'sync_meta["source"] = "cache_stale"' in SRC
assert 'sync_meta["stale"] = True' in SRC
# The manual-refresh contract the UI relies on must stay intact.
assert "manual_refresh = bool(refresh)" in SRC
assert "_invalidate_list_cache(account_id, folder)" in SRC
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
+1 -1
View File
@@ -64,7 +64,7 @@ def test_imap_connect_with_host_still_connects(monkeypatch):
def login(self, user, password):
opened["login"] = (user, password)
def _fake_open(host, port, *, starttls, timeout, **_policy):
def _fake_open(host, port, *, starttls, timeout):
opened["host"] = host
return _FakeConn()
+9 -9
View File
@@ -12,7 +12,7 @@ from routes.email_helpers import _send_smtp_message
class _FakeSMTP:
calls = []
def __init__(self, host, port, timeout=None, **_policy):
def __init__(self, host, port, timeout=None):
self.host = host
self.port = port
self.timeout = timeout
@@ -54,8 +54,8 @@ def test_send_smtp_message_supports_plain_smtp(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
@@ -68,8 +68,8 @@ def test_send_smtp_message_supports_explicit_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
@@ -82,8 +82,8 @@ def test_send_smtp_message_defaults_587_to_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
cfg = _cfg("", port=587)
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
@@ -96,8 +96,8 @@ def test_send_smtp_message_uses_ssl_when_configured(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
+12 -12
View File
@@ -89,8 +89,8 @@ async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -141,8 +141,8 @@ async def test_test_connection_password_account_still_uses_login():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn):
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is True
@@ -187,8 +187,8 @@ async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -238,8 +238,8 @@ async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -336,10 +336,10 @@ async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_helpers._PolicyIMAP4",
"routes.email_helpers.imaplib.IMAP4",
return_value=starttls_conn,
) as imap_cls, mock.patch(
"routes.email_helpers._PolicyIMAP4_SSL",
"routes.email_helpers.imaplib.IMAP4_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as imap_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
@@ -412,10 +412,10 @@ async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_routes._PolicySMTP",
"routes.email_routes.smtplib.SMTP",
return_value=starttls_smtp,
) as smtp_cls, mock.patch(
"routes.email_routes._PolicySMTP_SSL",
"routes.email_routes.smtplib.SMTP_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as smtp_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
+155
View File
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
def _form_fields(content, content_type):
"""Parse simple multipart form fields (text parts only) from a request body."""
boundary = content_type.split('boundary=')[1].strip().strip('"')
fields = {}
for part in content.split(('--' + boundary).encode()):
if b'name="' not in part:
continue
head, _, body = part.partition(b'\r\n\r\n')
import re as _re
match = _re.search(rb'name="([^"]+)"', head)
if not match:
continue
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
return fields
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
base_url = 'http://100.69.120.65:8188/v1'
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
_real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient',
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
class Request:
async def json(self):
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
if include_mask:
payload['mask'] = png(mask)
payload.update(body_extra)
return payload
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
return asyncio.run(route.endpoint(Request()))
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
seen = {}
def respond(request):
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
Image.new('L', (1600, 1200), 255),
{'steps': 8}, respond)
# Working size is capped to a model-friendly size (<=768 long side, /16).
assert seen['size'] == '768x576'
# A requested step budget passes through up to the cap (default 8).
assert seen['steps'] == '8'
# The returned image keeps the original dimensions.
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
# Over-cap requests are clamped to the configured maximum (default 8).
assert seen['steps'] == '8'
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
Image.new('L', (640, 480), 255), {}, respond)
# Under the cap: dimensions pass through untouched; with no requested count
# sent the moderate default (4) is used.
assert seen['size'] == '640x480'
assert seen['steps'] == '4'
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
"""A whole-image instruction edit may omit the mask; it must not raise
KeyError -> 400 (the health-audit regression)."""
seen = {}
mask_bytes = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
# The provider must receive a (full-white) mask, not a missing one.
import re as _re
parts = request.content.split(b'--')
for part in parts:
if b'name="mask"' in part:
head, _, body = part.partition(b'\r\n\r\n')
mask_bytes['png'] = body.rstrip(b'\r\n-')
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
None, {}, respond, include_mask=False)
assert 'image' in out, out
assert seen.get('size') == '32x32'
# The synthesized mask is image-sized and all-white (whole image editable).
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
assert mask_img.size == (32, 32)
assert mask_img.getextrema() == (255, 255)
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
KeyError traceback."""
from fastapi import HTTPException
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
class Request:
async def json(self):
return {'_endpoint': 'https://api.openai.com/v1',
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
try:
asyncio.run(route.endpoint(Request()))
raise AssertionError('expected a 400 for a missing mask')
except HTTPException as exc:
assert exc.status_code == 400
assert 'mask' in str(exc.detail).lower()
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
"""A client-supplied request_id is forwarded to the provider so the client
can poll GET /api/image/edit-progress for step progress."""
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
assert 'image' in out, out
assert seen.get('request_id') == 'job-777'
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the image-op progress contract (HARNESS-owned).
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
``/v1/images/progress/{request_id}`` route. When the provider has no such route
the endpoint returns ``{"available": false}`` so the client keeps an
indeterminate bar.
"""
import asyncio
from types import SimpleNamespace
import httpx
import routes.gallery_routes as gallery
def _route(path):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
class _Req:
def __init__(self, query):
self.query_params = query
def _fake_db():
class Db:
def close(self):
pass
def query(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def first(self):
return None
return Db()
def test_edit_progress_requires_request_id(monkeypatch):
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
route = _route("/api/image/edit-progress")
try:
asyncio.run(route.endpoint(_Req({})))
except Exception as exc: # FastAPI HTTPException
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("expected a 400 for a missing request_id")
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
route = _route("/api/image/edit-progress")
try:
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("expected a 400 for an unsafe request_id")
def test_edit_progress_returns_provider_payload(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
seen = {}
def respond(request):
seen["url"] = str(request.url)
# Live seneca contract stages: loading | editing | compositing | done | error.
return httpx.Response(
200,
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
)
client = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
assert out["available"] is True
assert out["stage"] == "editing" # passed through unchanged
assert out["step"] == 3 and out["steps"] == 8
assert seen["url"].endswith("/images/progress/abc-123")
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient",
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
)
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
assert out["available"] is False
def test_edit_progress_available_false_when_unreachable(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
class Boom:
async def __aenter__(self):
raise httpx.ConnectError("nope")
async def __aexit__(self, *a):
return False
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
assert out["available"] is False
+201
View File
@@ -0,0 +1,201 @@
"""Heavy in-process image ops must not load GB-scale models by default.
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
models inside the web process and OOM-killed the host. They now default to a
graceful 503, proxy to the seneca remote ops service when
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
"""
import asyncio
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.gallery import gallery_routes as gr
HEAVY_ROUTES = [
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
("/api/image/mask", {"image": "x"}, "Smart mask"),
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
("/api/image/denoise", {"image": "x"}, "AI denoise"),
]
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
return TestClient(app)
def test_local_image_models_disabled_by_default():
assert gr._local_image_models_enabled() is False
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
def test_local_image_models_enabled_env(monkeypatch, value):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
assert gr._local_image_models_enabled() is True
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
def test_local_image_models_disabled_env(monkeypatch, value):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
assert gr._local_image_models_enabled() is False
def test_rss_mb_is_positive():
assert gr._rss_mb() > 0
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
def test_heavy_ops_503_when_disabled(client, path, body, label):
resp = client.post(path, json=body)
assert resp.status_code == 503
detail = resp.json().get("detail", "")
assert "temporarily unavailable" in detail
assert label in detail
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
import sys
monkeypatch.setitem(sys.modules, "rembg", None)
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
client = TestClient(app)
resp = client.post("/api/image/remove-bg", json={"image": "x"})
assert resp.status_code == 503
class _FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def json(self):
return self._payload
class _FakeAsyncClient:
calls = []
response = _FakeResponse({})
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, url, json=None, files=None, data=None):
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
return _FakeAsyncClient.response
def _client():
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
return TestClient(app)
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse(
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
)
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
assert resp.status_code == 200
assert resp.json()["image"] == "RB"
url, kw = _FakeAsyncClient.calls[-1]
assert url == "http://seneca.test:8188/v1/images/remove-background"
assert "image" in kw["files"]
def test_mask_proxies_to_remote_contract(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse(
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
"model": "sam+grounding"}
)
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
body = resp.json()
assert body["mask"] == "MASK"
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
assert body["bbox"] == [10, 20, 40, 60]
url, kw = _FakeAsyncClient.calls[-1]
assert url == "http://seneca.test:8188/v1/images/mask"
assert kw["data"]["prompt"] == "person"
def test_mask_picks_top_score_not_first(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse(
{"masks": [
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
], "model": "sam+grounding"}
)
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
assert resp.json()["mask"] == "HIGH"
def test_mask_box_is_sent_as_xywh(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
assert resp.status_code == 200
_, kw = _FakeAsyncClient.calls[-1]
assert kw["data"]["box"] == "10,20,30,40"
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
body = resp.json()
assert body["mask"] is None
assert body["masks"] == []
assert "Nothing selected" in body["error"]
def test_remote_proxy_surfaces_error_status(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
assert resp.status_code == 503
assert "temporarily unavailable" in resp.json().get("detail", "")
@@ -0,0 +1,124 @@
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
Chat image generation resolves its model by name across every enabled endpoint,
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
OpenAI-compatible gateway works there. The editor's generative ops used a
stricter image-only lookup and reported "No image generation endpoint
configured" on those same setups. These tests pin the fallback that makes the
editor find the same endpoint.
"""
from types import SimpleNamespace
import routes.gallery_routes as gallery_routes
class _Predicate:
def __init__(self, check):
self._check = check
def __call__(self, row):
return self._check(row)
def __or__(self, other):
return _Predicate(lambda row: self(row) or other(row))
class _Column:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return _Predicate(lambda row: getattr(row, self.name) == value)
class _ModelEndpoint:
base_url = _Column("base_url")
model_type = _Column("model_type")
is_enabled = _Column("is_enabled")
owner = _Column("owner")
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *predicates):
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
return self
def all(self):
return list(self._rows)
class _DB:
def __init__(self, rows):
self._rows = rows
def query(self, model):
assert model is _ModelEndpoint
return _Query(self._rows)
def _ep(base_url, owner=None, *, enabled=True, model_type="chat", models=None):
return SimpleNamespace(
base_url=base_url,
owner=owner,
is_enabled=enabled,
model_type=model_type,
api_key="sk-secret",
cached_models=models or [],
pinned_models=[],
)
def _patch_model(monkeypatch):
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
_patch_model(monkeypatch)
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
assert ep is not None
assert ep.base_url == "https://gateway.example.com/v1"
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
_patch_model(monkeypatch)
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
_patch_model(monkeypatch)
rows = [
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
_ep("https://images.example.com/v1", model_type="image"),
]
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
assert ep is not None
assert ep.model_type == "image"
def test_unreachable_endpoint_reports_502():
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
502 (gateway) is reserved for "the image server could not be reached"; 503 is
reserved for "generation is switched off on the server", which the editor maps
to its own fixed message.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
).read_text()
assert "Image server unreachable" in src
assert "except httpx.TransportError as exc:" in src
assert "provider HTTP" in src
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
+27
View File
@@ -0,0 +1,27 @@
"""Source checks for the gallery Settings-tab image-model picker.
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
def test_settings_tab_has_image_model_picker():
assert 'id="gallery-image-model"' in GALLERY_JS
assert "/api/editor/image-models" in GALLERY_JS
assert "ge-ai-command-model" in GALLERY_JS
def test_image_model_picker_lives_in_the_settings_container():
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
assert picker_idx > settings_idx
def test_picker_persists_with_the_shared_localstorage_key():
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY = ROOT / "static/js/gallery.js"
def test_editor_landing_has_an_import_button_next_to_new_canvas():
"""The editor landing must offer a direct "Import" action.
It sits in the same action row as "New canvas..." / "Browse gallery" and,
on file pick, opens the editor on the chosen raster image (``openEditor``
takes an image data URL as its first argument).
"""
src = GALLERY.read_text(encoding="utf-8")
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
# Button order: New canvas → Import → Browse gallery.
new_at = src.index('id="gallery-editor-new"')
import_at = src.index('id="gallery-editor-import"')
pick_at = src.index('id="gallery-editor-pick"')
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
# Wiring opens the editor on the selected file's data URL.
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
assert "readAsDataURL(file)" in src
assert "await openEditor(reader.result, null, null, name)" in src
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the seneca single-image op proxies (HARNESS-owned).
POST /api/image/replace-background -> provider /v1/images/replace-background
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
return the provider image as ``{image}``.
"""
import asyncio
import base64
from types import SimpleNamespace
import httpx
import routes.gallery_routes as gallery
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
def _route(path):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
class _Req:
def __init__(self, body):
self._body = body
async def json(self):
return self._body
def _fake_db():
class Db:
def close(self):
pass
def query(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def first(self):
return None
return Db()
def _setup(monkeypatch, respond):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
seen = {}
def _respond(request):
seen["url"] = str(request.url)
seen["body"] = bytes(request.content)
return respond(request)
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient",
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
)
return seen
def _ok(_req):
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
"harmonise": True, "shadow": False, "request_id": "rb-123",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/replace-background")
body = seen["body"]
assert b"put her on a beach" in body
assert b"rb-123" in body # request_id forwarded for progress
assert b"harmonise" in body and b"shadow" in body
assert b"8" in body # steps
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
"request_id": "op-9",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/outpaint")
body = seen["body"]
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
assert token in body, token
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/outpaint")
def test_outpaint_reads_openai_style_url_result(monkeypatch):
def _respond(_req):
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
_setup(monkeypatch, _respond)
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
assert out["image"] == "ZZZZ"
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
try:
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
except Exception as exc:
assert getattr(exc, "status_code", None) == 502
else:
raise AssertionError("expected a 502 when the provider returns no image")
async def _async_return(value):
return value
def _capture_forward(monkeypatch):
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
captured = {}
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
captured["label"] = label
captured["provider_path"] = provider_path
captured["timeout"] = timeout
return {"image": "AAAA"}
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(
gallery, "_first_visible_image_endpoint",
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
)
return captured
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
assert out["image"] == "AAAA"
assert captured["provider_path"] == "/images/outpaint"
assert captured["timeout"] >= 900, captured
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
assert captured["timeout"] >= 900, captured
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
assert captured["timeout"] == 300.0, captured
def test_outpaint_timeout_env_override(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
assert gallery._outpaint_timeout() == 720.0
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
assert gallery._outpaint_timeout() == 900.0
def test_graceful_shutdown_window_covers_a_long_outpaint():
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
+23 -54
View File
@@ -1,11 +1,5 @@
import pytest
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, is_prequantized
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
# Rank authored inputs rather than publication catalog snapshots.
pytestmark = pytest.mark.usefixtures("publication_catalog")
def _8gb_vram_system():
@@ -20,63 +14,38 @@ def _8gb_vram_system():
}
GEMMA = "google/gemma-4-12B-it"
def test_gemma4_12b_in_catalog():
catalog = {m["name"]: m for m in get_models()}
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
def _input_row(rows, name):
return next(r for r in rows if r["name"] == name)
def _qat_rows(rows):
return [r for r in rows if r["name"].startswith(GEMMA + "-qat-")]
def test_gemma4_12b_user_catalog_row_wins_the_merge(publication_catalog, monkeypatch):
"""A dynamic cache listing the same repo must not replace or duplicate the
user-catalog row; rows only the cache knows are still merged in."""
from services.hwfit import hf_discovery
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [
{"name": GEMMA, "quantization": "F16", "gguf_sources": [{"repo": "test/cache-GGUF", "file": "cache.gguf"}]},
{"name": "test/cache-only", "quantization": "F16", "gguf_sources": []},
])
merged = get_models()
names = [m["name"] for m in merged]
assert names.count(GEMMA) == 1
assert "test/cache-only" in names
entry = next(m for m in merged if m["name"] == GEMMA)
assert entry["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
def test_gemma4_12b_ranked_row_carries_its_gguf_source(publication_catalog):
hit = next(r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20) if r["name"] == GEMMA)
assert hit["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
# The F16 input does not fit 8 GB, so the fit falls back to a GGUF quant.
assert hit["quant"] == "Q4_K_M"
def test_gemma4_12b_has_gguf_source():
catalog = {m["name"]: m for m in get_models()}
entry = catalog["google/gemma-4-12B-it"]
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
repos = [s["repo"] for s in entry["gguf_sources"]]
assert "unsloth/gemma-4-12B-it-GGUF" in repos
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
names = [r["name"] for r in results]
assert GEMMA in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
def test_gemma4_12b_qat_entries_rank_with_their_native_quant(publication_catalog):
qat = _qat_rows(publication_catalog)
assert len(qat) == 2
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
for row in qat:
assert ranked[row["name"]]["quant"] == row["quantization"]
def test_gemma4_12b_qat_entries_are_prequantized(publication_catalog):
def test_gemma4_12b_qat_entries_in_catalog():
catalog = {m["name"]: m for m in get_models()}
for row in _qat_rows(publication_catalog):
assert is_prequantized(catalog[row["name"]])
assert "google/gemma-4-12B-it-qat-int4" in catalog
assert "google/gemma-4-12B-it-qat-int8" in catalog
def test_gemma4_12b_qat_entries_have_no_gguf(publication_catalog):
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
for row in _qat_rows(publication_catalog):
assert ranked[row["name"]]["gguf_sources"] == []
def test_gemma4_12b_qat_entries_are_prequantized():
catalog = {m["name"]: m for m in get_models()}
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
def test_gemma4_12b_qat_entries_have_no_gguf():
catalog = {m["name"]: m for m in get_models()}
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
+9 -90
View File
@@ -3,33 +3,8 @@
The handler did `n = int(gpu_count)` with no guard, so `?gpu_count=abc` (or any
non-integer) raised ValueError -> HTTP 500. A malformed count is now ignored,
matching how the neighbouring gpu_group param is already parsed.
The count parsing runs only after the empty-catalog early return, so these
tests rank the authored publication catalog against a fixed two-GPU system and
spy on rank_models to prove the parsing path actually ran.
"""
import functools
from copy import deepcopy
import pytest
from routes.hwfit_routes import setup_hwfit_routes
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
SYSTEM = {
"has_gpu": True,
"backend": "cuda",
"gpu_name": "Synthetic GPU",
"gpu_vram_gb": 24.0,
"gpu_count": 2,
"gpus": [
{"index": 0, "name": "Synthetic GPU", "vram_gb": 12.0},
{"index": 1, "name": "Synthetic GPU", "vram_gb": 12.0},
],
"gpu_groups": [{"name": "Synthetic GPU", "vram_each": 12.0, "count": 2, "indices": [0, 1], "vram_total": 24.0}],
"available_ram_gb": 32.0,
"total_ram_gb": 32.0,
}
def _get_models():
@@ -40,80 +15,24 @@ def _get_models():
raise AssertionError("hwfit /models route not found")
@pytest.fixture
def ranked_systems(publication_catalog, monkeypatch):
"""Fix detection and record every system the handler hands to rank_models."""
from services.hwfit import fit, hardware
monkeypatch.setattr(hardware, "detect_system", lambda **_: deepcopy(SYSTEM))
calls = []
real_rank = fit.rank_models
@functools.wraps(real_rank)
def spy(system, **kwargs):
calls.append(deepcopy(system))
return real_rank(system, **kwargs)
monkeypatch.setattr(fit, "rank_models", spy)
return calls
def _assert_ranked(result, ranked_systems):
assert isinstance(result, dict)
assert "error" not in result
assert isinstance(result["models"], list) and result["models"]
# Reaching rank_models means the empty-catalog return was not taken and the
# manual-hardware and gpu_count parsing ran first.
assert len(ranked_systems) == 1
assert ranked_systems[0] == result["system"]
return result["system"]
def test_non_numeric_gpu_count_does_not_raise(ranked_systems):
def test_non_numeric_gpu_count_does_not_raise():
handler = _get_models()
# Previously raised ValueError (HTTP 500); now degrades to a normal ranking.
system = _assert_ranked(handler(gpu_count="abc"), ranked_systems)
# Ignored like an omitted count: the auto pool keeps both GPUs and no
# explicit-count GPU-only pin is applied.
assert system["detected_gpu_count"] == 2
assert system["active_group"]["use_count"] == 2
assert system["gpu_count"] == 2
assert "gpu_only" not in system
result = handler(gpu_count="abc")
assert isinstance(result, dict)
def test_numeric_gpu_count_still_accepted(ranked_systems):
def test_numeric_gpu_count_still_accepted():
handler = _get_models()
system = _assert_ranked(handler(gpu_count="0"), ranked_systems)
# 0 switches to RAM-only ranking.
assert system["detected_gpu_count"] == 2
assert system["has_gpu"] is False
assert system["gpu_count"] == 0
assert system["gpu_only"] is False
assert "active_group" not in system
result = handler(gpu_count="0")
assert isinstance(result, dict)
def test_non_numeric_manual_gpu_count_does_not_raise(ranked_systems):
def test_non_numeric_manual_gpu_count_does_not_raise():
# manual_gpu_count is the other count param on this endpoint (the hardware
# simulator in _apply_manual_hardware). A non-numeric value must also degrade
# (default to 1) rather than 500, so the endpoint's count parsing is fully
# covered.
handler = _get_models()
system = _assert_ranked(handler(manual_mode="gpu", manual_gpu_count="abc"), ranked_systems)
assert system["manual_hardware"] is True
assert system["gpu_name"] == "Simulated CUDA GPU"
assert system["gpu_count"] == 1
assert system["gpu_groups"][0]["count"] == 1
assert system["detected_gpu_count"] == 1
def test_empty_catalog_returns_before_count_parsing(ranked_systems, monkeypatch):
# Control: without catalog rows the handler stops before the parsing above,
# which is why the tests in this module must rank the authored catalog.
from services.hwfit import models
monkeypatch.setattr(models, "get_models", lambda: [])
result = _get_models()(gpu_count="abc", manual_mode="gpu", manual_gpu_count="abc")
assert result["models"] == []
assert "Model catalog is empty" in result["error"]
assert ranked_systems == []
assert "detected_gpu_count" not in result["system"]
result = handler(manual_mode="gpu", manual_gpu_count="abc")
assert isinstance(result, dict)
+10 -17
View File
@@ -6,15 +6,9 @@ guarantee that non-macOS (Linux/Windows) detection is unchanged.
import json
import pytest
from services.hwfit import hardware
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
# Rank authored inputs rather than publication catalog snapshots.
pytestmark = pytest.mark.usefixtures("publication_catalog")
def _metal_system(ram_gb=16.0, vram_gb=10.7):
@@ -84,20 +78,19 @@ def test_only_gguf_or_mlx_models_recommended_on_metal():
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
def test_qwen_gguf_rows_rank_with_their_gguf_downloads_on_metal(publication_catalog):
"""Qwen GGUF-looking Cookbook rows must download the GGUF repo/file their
catalog row names, not the base safetensors repository."""
def test_qwen_catalog_entries_point_at_verified_gguf_repos():
"""Qwen GGUF-looking Cookbook rows must download GGUF repos, not the base
safetensors repositories."""
catalog = {m["name"]: m for m in get_models()}
expected = {
r["name"]: r["gguf_sources"]
for r in publication_catalog
if r["name"].startswith("Qwen/Qwen3.") and r["gguf_sources"]
"Qwen/Qwen3.5-9B": ("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"),
"Qwen/Qwen3.6-27B": ("unsloth/Qwen3.6-27B-GGUF", "Qwen3.6-27B-Q4_K_M.gguf"),
"Qwen/Qwen3.6-35B-A3B": ("unsloth/Qwen3.6-35B-A3B-GGUF", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"),
}
assert len(expected) == 3
ranked = {r["name"]: r for r in rank_models(_metal_system(), search="Qwen/Qwen3.", limit=50)}
for model_name, sources in expected.items():
assert ranked[model_name]["gguf_sources"] == sources
assert all(src["repo"] != model_name and src["file"].endswith(".gguf") for src in ranked[model_name]["gguf_sources"])
for model_name, (repo, filename) in expected.items():
sources = catalog[model_name].get("gguf_sources") or []
assert any(src.get("repo") == repo and src.get("file") == filename for src in sources)
def test_safetensors_models_still_recommended_on_cuda():
+4 -10
View File
@@ -1,15 +1,9 @@
import pytest
from services.hwfit.fit import analyze_model, rank_models
from services.hwfit.models import (
get_models,
infer_quantization_from_name,
is_prequantized,
)
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
# Rank authored inputs rather than publication catalog snapshots.
pytestmark = pytest.mark.usefixtures("publication_catalog")
def _dual_5060ti_system():
@@ -41,7 +35,7 @@ def test_infers_native_hf_quant_formats_from_repo_names():
def test_nvfp4_catalog_quant_is_preserved():
catalog = {m["name"]: m for m in get_models()}
model = catalog["test/native-NVFP4"]
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
assert model["quantization"] == "NVFP4"
assert is_prequantized(model)
@@ -49,7 +43,7 @@ def test_nvfp4_catalog_quant_is_preserved():
def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
catalog = {m["name"]: m for m in get_models()}
model = catalog["test/native-NVFP4"]
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
fit = analyze_model(model, _dual_5060ti_system())
assert fit["quant"] == "NVFP4"
@@ -57,10 +51,10 @@ def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
results = rank_models(
_dual_5060ti_system(),
search="native-NVFP4",
search="Qwen3.5-122B-A10B-NVFP4",
limit=10,
)
hit = next(r for r in results if r["name"] == "test/native-NVFP4")
hit = next(r for r in results if r["name"] == "txn545/Qwen3.5-122B-A10B-NVFP4")
assert hit["quant"] == "NVFP4"
assert hit["run_mode"] != "cpu_offload"
+4 -2
View File
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
@pytest.mark.asyncio
@pytest.mark.parametrize('incognito', [False, True])
@pytest.mark.parametrize('exit_code', [None, 0])
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
from types import SimpleNamespace
from src import ai_interaction, tool_execution
from src.agent_runtime.journal import ActionJournal, bind_journal
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
_, denied = await execute(block, owner='pewds', session_id='fixture',
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
_, result = await execute(block, owner='pewds', session_id='fixture',
client_runtime_context={'web_ui_state': {'incognito': incognito}},
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
assert denied['exit_code'] != 0
assert journal.actions[0].execution_id is None
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
assert journal.actions[1].execution_id is not None
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
assert result['image_id'] == 'test'
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
@pytest.mark.asyncio
+54
View File
@@ -0,0 +1,54 @@
"""The heavy image-edit CPU work must run off the event loop, and the app must
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
not drop a 60-150 s edit."""
import base64
import io
from pathlib import Path
from PIL import Image
from routes.gallery import gallery_routes as gr
ROOT = Path(__file__).resolve().parents[1]
def _png_b64(w, h, color=(120, 160, 210)):
buf = io.BytesIO()
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def test_prepare_local_edit_caps_large_input():
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
assert orig == (4000, 3000)
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
assert mask # a full-white mask is synthesised when none is supplied
assert Image.open(io.BytesIO(src)).size == (mw, mh)
def test_prepare_local_edit_leaves_small_input_unchanged():
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
assert orig == (96, 96) and (mw, mh) == (96, 96)
def test_restore_local_b64_resizes_back_to_original():
raw = _png_b64(768, 576)
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
# a same-size result is returned untouched
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
def test_inpaint_offloads_cpu_work_to_a_thread():
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
assert "asyncio.to_thread(\n _restore_local_b64" in src
def test_app_exposes_busy_count_and_graceful_shutdown():
src = (ROOT / "app.py").read_text()
assert "busy_image_ops" in src
assert '@app.get("/api/busy")' in src
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
assert "timeout_graceful_shutdown" in src
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
+32
View File
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
assert 'disabled' in result['error']
@pytest.mark.parametrize('editing', [False, True])
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1')
if editing:
result = asyncio.run(ai_interaction.do_edit_image(
'Private edit', str(source), model_spec='openai/gpt-5-image',
owner='alice', session_id='private-chat', incognito=True))
else:
result = asyncio.run(ai_interaction.do_generate_image(
'Private image\nopenai/gpt-5-image', owner='alice',
session_id='private-chat', incognito=True))
assert 'error' not in result
assert result['image_url'].startswith('data:image/png;base64,')
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
assert not result['image_id']
assert not rows
assert not (source.parent / 'generated').exists()
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
configure, source, requests, rows = image_transport
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
result = asyncio.run(ai_interaction.do_generate_image(
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
assert result['image_url'].startswith('data:image/png;base64,')
assert not rows
assert not (source.parent / 'generated').exists()
@pytest.fixture
def image_transport(monkeypatch, tmp_path):
rows = []
+1 -1
View File
@@ -281,7 +281,7 @@ def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
))
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
monkeypatch.setattr(helpers, "_PolicyIMAP4", lambda *a, **kw: conn)
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
raised = False
try:
+503
View File
@@ -0,0 +1,503 @@
"""Scoped ``photo_editor`` capability family: routing, tools, and the
Quick Edit agent endpoint.
The family is context-only: it is offered to the model ONLY when an editor
session / edit-this-photo request explicitly selects it, never inferred from
message text and never part of the general tool list.
"""
import inspect
import pytest
from src.turn_contract import (
FAMILY_TOOLS,
requested_capabilities,
resolve_full_inventory_contract,
resolve_turn_contract,
)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
from src.tool_types import TOOL_TAGS
from src.agent_tools import TOOL_HANDLERS
from src.tool_index import BUILTIN_TOOL_DESCRIPTIONS
from src.tool_capabilities import KNOWN_CAPABILITY_TOOLS
from src.tool_security import PLAN_MODE_READONLY_TOOLS, _PLAN_MODE_KNOWN_MUTATORS
PHOTO_TOOLS = frozenset(
{"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image"}
)
FAMILY = FAMILY_TOOLS["photo_editor"]
CORE_NAMES = frozenset({"bash", "python", "web_search", "web_fetch", "write_file"})
def _schema_names():
return {s["function"]["name"] for s in FUNCTION_TOOL_SCHEMAS}
def test_family_contains_scoped_tools_only():
assert FAMILY == PHOTO_TOOLS | {"generate_image"}
def test_contract_offers_only_editor_tools_and_ask_user():
contract = resolve_turn_contract(
capabilities={"photo_editor"},
schemas=FUNCTION_TOOL_SCHEMAS,
policy=ToolPolicy(),
required_capabilities={"photo_editor"},
)
assert contract.offered == FAMILY | {"ask_user", "update_plan"}
assert not (CORE_NAMES & contract.offered)
def test_photo_editor_is_never_inferred_from_message_text():
for message in (
"make it black and white",
"generate an image of a cat",
"remove the person on the left",
"add a full moon in the sky",
"blur the background behind the person",
):
caps = requested_capabilities(message)
assert "photo_editor" not in caps, message
def test_generate_image_keeps_its_own_family_without_leaking_photo_editor():
caps = requested_capabilities("Create a note and generate an image of a cat")
assert "image_generation" in caps
assert "photo_editor" not in caps
def test_editor_tools_are_registered_everywhere():
schema_names = _schema_names()
for name in PHOTO_TOOLS:
assert name in TOOL_TAGS, name
assert name in TOOL_HANDLERS, name
assert name in BUILTIN_TOOL_DESCRIPTIONS, name
assert name in KNOWN_CAPABILITY_TOOLS, name
assert name in schema_names, name
assert "generate_image" in FAMILY
def test_editor_tools_plan_mode_partition():
assert "get_editor_screenshot" in PLAN_MODE_READONLY_TOOLS
for name in ("plan_edit", "run_editor_command", "ai_edit_image"):
assert name in _PLAN_MODE_KNOWN_MUTATORS, name
def test_full_inventory_contains_the_editor_tools():
preview = resolve_full_inventory_contract(
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()
)
assert FAMILY <= set(preview.offered)
def test_scope_preview_contract_keeps_only_the_editor_family():
from dataclasses import replace
from src.clean_agent_preview import scope_preview_contract
preview = resolve_full_inventory_contract(
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()
)
routed = resolve_turn_contract(
capabilities={"photo_editor"},
schemas=FUNCTION_TOOL_SCHEMAS,
policy=ToolPolicy(),
required_capabilities={"photo_editor"},
)
scoped = scope_preview_contract(
replace(preview, selection_mode="full_compact_experiment"),
routed,
frozenset({"photo_editor"}),
extra_tools=frozenset({"ask_user"}),
)
assert FAMILY <= set(scoped.offered)
assert "ask_user" in scoped.offered
assert not (CORE_NAMES & set(scoped.offered))
def test_editor_agent_endpoint_reports_scoped_tools():
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.editor_agent_routes as eaar
eaar.set_editor_planner(lambda messages, owner: {"reply": "ok", "steps": [], "done": True})
try:
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
client = TestClient(app)
resp = client.post(
"/api/editor/agent",
json={"prompt": "hello there", "session": "s1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert PHOTO_TOOLS <= set(data["tools_offered"])
assert "generate_image" in data["tools_offered"]
assert not (CORE_NAMES & set(data["tools_offered"]))
assert data["trace"][0]["type"] == "session"
finally:
eaar.set_editor_planner(None)
async def _route_contract(monkeypatch, message, *, attachment_meta=()):
from routes import chat_routes
from src import tool_security
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
with monkeypatch.context() as route_patch:
endpoint = _chat_stream_endpoint(
route_patch, "agent", {},
session_model="odysseus-qwen3.5-tools-pre-heretic",
attachment_meta=attachment_meta,
)
route_patch.setattr(
chat_routes, "coerce_message_and_session",
lambda *args, **kwargs: (message, "session-1"),
)
route_patch.setattr(
tool_security, "owner_is_admin_or_single_user", lambda owner: True,
)
observed = []
async def capture_agent(*args, **kwargs):
observed.append(kwargs["turn_contract"])
yield 'data: {"delta":"ok"}\n\n'
yield "data: [DONE]\n\n"
route_patch.setattr(chat_routes, "stream_agent_loop", capture_agent)
request = _RouteRequest("agent")
request._form.update({
"message": message, "compare_mode": "false",
"allow_web_search": "false", "allow_bash": "false",
})
response = await endpoint(request)
async for _ in response.body_iterator:
pass
assert len(observed) == 1
return observed[0]
@pytest.mark.asyncio
async def test_normal_chat_turn_offers_no_editor_tools(monkeypatch):
for message in ("what is the capital of France", "write a haiku"):
contract = await _route_contract(monkeypatch, message)
assert "photo_editor" not in contract.capabilities, message
assert not (PHOTO_TOOLS & set(contract.offered)), message
@pytest.mark.asyncio
async def test_attached_photo_edit_selects_photo_editor_family(monkeypatch):
contract = await _route_contract(
monkeypatch,
"make this black and white",
attachment_meta=[{"id": "att1", "mime": "image/png", "name": "x.png"}],
)
assert contract.capabilities == {"photo_editor"}
assert set(contract.offered) == FAMILY | {"ask_user"}
assert not (CORE_NAMES & set(contract.offered))
def test_chat_context_guides_open_or_absent_editor():
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py").read_text(
encoding="utf-8"
)
assert "The image editor is open (session " in src
assert "no image editor is open" in src
assert "the attached image in the editor (Quick Edit)" in src
def test_generative_intent_classification():
from routes.editor_agent_routes import _generative_kind
assert _generative_kind("remove the person on the left") == "remove"
assert _generative_kind("erase the sign in the background") == "remove"
assert _generative_kind("add a full moon in the sky") == "add"
assert _generative_kind("put a bird on the fence") == "add"
assert _generative_kind("replace the sky with a sunset") == "replace"
assert _generative_kind("change the background to night") == "replace"
# Non-generative edits must stay deterministic.
assert _generative_kind("make it black and white") is None
assert _generative_kind("make it brighter") is None
assert _generative_kind("add a vignette") is None
assert _generative_kind("remove the background") is None
assert _generative_kind("sharpen the image") is None
def test_generative_step_uses_ai_content_tools():
from routes.editor_agent_routes import _generative_step
assert _generative_step("remove", "remove the person")["op"] == "ai_edit_image"
assert _generative_step("replace", "replace the sky")["op"] == "ai_edit_image"
# Adding content to an existing photo is an instruction edit, not generate_image.
assert _generative_step("add", "add a moon")["op"] == "ai_edit_image"
assert _generative_step("add", "add a moon")["params"]["mode"] == "instruction"
# generate_image only for a brand-new image.
assert _generative_step("add", "create a new image of a moon")["op"] == "generate_image"
def test_generative_mode_instruction_vs_masked():
from routes.editor_agent_routes import _generative_mode
# Masked only for an explicit regional removal of a clearly identified object.
assert _generative_mode("remove", "remove the person on the left") == "masked"
# Diffuse cleanup, replacement, season/style and additions use instruction mode.
assert _generative_mode("remove", "remove the distractions") == "instruction"
assert _generative_mode("replace", "make the background nicer") == "instruction"
assert _generative_mode("add", "add a full moon in the sky") == "instruction"
def test_ensure_generative_replaces_deterministic_approximations():
from routes.editor_agent_routes import _ensure_generative
bad = [{"op": "selection.rectangle", "params": {}}, {"op": "adjustments.apply", "params": {}}]
removal = _ensure_generative("remove the person on the left", list(bad))
# A mask selection step now precedes the masked AI edit.
assert removal[-1]["op"] == "ai_edit_image"
assert removal[-1]["params"]["mode"] == "masked"
assert all(s["op"].startswith("selection.") for s in removal[:-1])
# Adding an object to an existing photo is an instruction edit (no selection).
addition = _ensure_generative("add a full moon in the sky", list(bad))
assert addition[-1]["op"] == "ai_edit_image"
assert addition[-1]["params"]["mode"] == "instruction"
# Non-generative plans are left untouched.
assert _ensure_generative("make it black and white", list(bad)) == bad
def test_fallback_plan_routes_generative_intents():
import routes.editor_agent_routes as eaar
removal = eaar._fallback_plan("remove the person on the left")
assert removal["steps"][-1]["op"] == "ai_edit_image"
assert removal["steps"][-1]["params"]["mode"] == "masked"
addition = eaar._fallback_plan("add a full moon in the sky")
assert addition["steps"][-1]["op"] == "ai_edit_image"
assert addition["steps"][-1]["params"]["mode"] == "instruction"
plain = eaar._fallback_plan("make it black and white")
assert plain["steps"][0]["op"] == "adjustments.apply"
def test_provider_message_is_actionable():
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
assert "image generation provider" in IMAGE_PROVIDER_REQUIRED_MESSAGE
assert "Settings > Models" in IMAGE_PROVIDER_REQUIRED_MESSAGE
def test_image_provider_respects_disabled_setting(monkeypatch):
import src.settings as settings
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False})
from src.ai_interaction import image_editing_provider_available
assert image_editing_provider_available(None) is False
def _agent_client():
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.editor_agent_routes as eaar
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return eaar, TestClient(app)
@pytest.mark.parametrize(
"prompt",
["remove the person on the left", "add a full moon in the sky"],
)
def test_editor_agent_blocks_generative_without_provider(monkeypatch, prompt):
"""No image provider -> zero canvas changes + a plain provider message."""
eaar, client = _agent_client()
# Planner proposes an unrelated deterministic edit, as the buggy run did.
eaar.set_editor_planner(
lambda messages, owner: {"reply": "done", "steps": [
{"op": "selection.rectangle", "params": {}},
{"op": "adjustments.apply", "params": {}},
]}
)
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
try:
resp = client.post(
"/api/editor/agent", json={"prompt": prompt, "session": "s1"}
)
assert resp.status_code == 200
data = resp.json()
assert data["needs_provider"] is True
assert data["blocked"] is True
assert "image generation provider" in data["message"]
assert data["image_provider"] is False
assert data["results"] == []
# Nothing was executed through the bridge (zero canvas changes).
assert not any(t.get("type") == "step" for t in data["trace"])
assert any(t.get("reason") == "image_provider_unavailable" for t in data["trace"])
# The proposed deterministic plan was replaced by the correct AI op.
ops = [s["op"] for s in data["steps"]]
assert ops[-1] in ("ai_edit_image", "generate_image")
finally:
eaar.set_editor_planner(None)
def test_editor_agent_prefers_ai_op_over_deterministic_when_provider_present(monkeypatch):
eaar, client = _agent_client()
eaar.set_editor_planner(
lambda messages, owner: {"reply": "done", "steps": [
{"op": "selection.rectangle", "params": {}},
{"op": "adjustments.apply", "params": {}},
]}
)
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
async def _instant_wait_for(awaitable, *args, **kwargs):
# The plan now starts with a mask-selection bridge step; resolve it
# immediately instead of waiting for a (non-existent) editor subscriber.
try:
awaitable.close()
except Exception:
pass
return {"ok": True, "result": {"width": 100, "height": 100}}
monkeypatch.setattr(eaar.asyncio, "wait_for", _instant_wait_for)
calls = []
class _FakeAiEdit:
async def execute(self, content, ctx):
calls.append(content)
return "ai_edit_image: fake", {"output": "ok", "exit_code": 0}
class _FakeGen:
async def execute(self, content, ctx):
calls.append(content)
return "generate_image: fake", {"output": "ok", "exit_code": 0}
import src.agent_tools.editor_tools as et
monkeypatch.setattr(et, "AiEditImageTool", _FakeAiEdit)
monkeypatch.setattr(et, "GenerateImageLayerTool", _FakeGen)
try:
resp = client.post(
"/api/editor/agent",
json={"prompt": "remove the person on the left", "session": "s1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
ops = [s["op"] for s in data["steps"]]
assert ops[-1] == "ai_edit_image"
assert any(r.get("op") == "ai_edit_image" for r in data["results"])
assert calls, "the AI edit tool should have been invoked"
assert "adjustments.apply" not in ops
finally:
eaar.set_editor_planner(None)
def test_editor_prompts_advertise_canonical_shapes():
import routes.editor_agent_routes as eaar
canonical = eaar._CANONICAL_GUIDANCE
assert "brightness-contrast" in canonical and "render-clouds" in canonical
assert canonical in eaar._SYSTEM_PROMPT
assert canonical in eaar._CHECK_SYSTEM_PROMPT
assert "_CANONICAL_GUIDANCE" in inspect.getsource(eaar.setup_editor_agent_routes)
def test_run_editor_command_schema_advertises_canonical_shapes():
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
schema = next(s for s in FUNCTION_TOOL_SCHEMAS
if s["function"]["name"] == "run_editor_command")
desc = schema["function"]["description"]
assert "brightness-contrast" in desc and "render-clouds" in desc
assert "vignette" in desc # names the banned invented ids
def test_photo_editor_chat_without_canvas_asks_to_open_editor():
from src import clean_agent_preview
source = inspect.getsource(clean_agent_preview.stream_preview)
assert "not open on the editor canvas" in source
assert "'photo_editor' in (getattr(turn_contract, 'active_capabilities'" in source
def test_chat_photo_edit_without_open_editor_short_circuits(monkeypatch):
"""A photo-edit chat turn with no open editor must not run the tool loop.
Regression: offering the editor tools with no canvas made the model loop
on plan_edit/run_editor_command until the round cap and end with an empty
answer. The runtime now answers deterministically and asks the user to
open the image in the editor.
"""
import asyncio
import json as _json
from dataclasses import replace
import src.clean_agent_preview as module
contract = replace(
resolve_full_inventory_contract(schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()),
active_capabilities=frozenset({"photo_editor"}),
)
provider_calls = []
class _Client:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def stream(self, *args, **kwargs):
provider_calls.append(kwargs.get("json"))
raise AssertionError("provider must not be called when no editor is open")
monkeypatch.setattr(module.httpx, "AsyncClient", _Client)
async def _run():
return [
chunk
async for chunk in module.stream_preview(
endpoint_url="http://test",
model="test",
headers={},
messages=[{"role": "user", "content": "make this black and white"}],
turn_contract=contract,
session_id="test",
owner="test",
disabled_tools=set(),
tool_policy=ToolPolicy(),
client_runtime_context={"web_ui_state": {"photo_editor_open": False}},
)
]
chunks = asyncio.run(_run())
events = [
_json.loads(chunk[6:])
for chunk in chunks
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
]
assert provider_calls == []
contract_event = next(e for e in events if e.get("type") == "turn_contract")
assert "photo_editor" in contract_event["active_capabilities"]
final = next(e for e in events if e.get("type") == "final_response")
lowered = final["content"].lower()
assert "editor" in lowered and "open" in lowered
# One-click affordance contract for the chat UI.
assert final["photo_editor_action"] == {
"kind": "open_and_apply",
"prompt": "make this black and white",
}
assert not any(e.get("type") == "tool_start" for e in events)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))
+642
View File
@@ -0,0 +1,642 @@
"""Unit tests for the shared photo-editor style guide and its server wiring.
The guide (``src/editor_style_guide.py``) is the single source of truth for
intent -> ops routing, calibrated strengths and the low-key / temperature /
portrait / ambiguity rules. It feeds both the server planner and (via
``GET /api/editor/style-guide``) the browser fallback.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.editor_agent_routes as eaar
from src import editor_style_guide as guide
@pytest.fixture(autouse=True)
def _reset_planner():
eaar.set_editor_planner(None)
yield
eaar.set_editor_planner(None)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return TestClient(app)
def _ops(steps):
return [s["op"] for s in steps]
def test_new_ops_are_in_the_server_op_catalog():
# If a new retouch/selection op is missing from OP_CATALOG, _parse_plan
# filters a valid plan step out and the edit silently disappears.
for name in guide.NEW_OP_NAMES:
assert name in eaar.OP_CATALOG
for name in ("photo.autoTone", "retouch.skin", "retouch.teeth",
"selection.face", "selection.background"):
assert name in eaar._OP_SET
def test_portrait_skin_intent_is_a_multi_step_plan():
plan = guide.fallback_plan("can you fix the skin a bit, look my best")
assert plan is not None
# No leading whole-image auto tone: it washes out the face. Go straight to a
# gentle face retouch, then clear the selection.
assert _ops(plan["steps"]) == [
"selection.face", "retouch.skin", "selection.deselect",
]
skin = plan["steps"][1]["params"]
assert 0 < skin["smooth"] <= 14
assert 0 < skin["opacity"] <= 0.45
# No global lift anywhere in the portrait chain (client never brightens skin).
assert not any("autoTone" in op or "brightness" in op or "exposure" in op
for op in _ops(plan["steps"]))
def test_portrait_intent_does_not_lead_with_auto_tone():
for prompt in ("make me look better", "fix the skin", "fix my teeth",
"smooth my complexion", "portrait touch up"):
plan = guide.fallback_plan(prompt)
assert plan is not None, prompt
assert "photo.autoTone" not in _ops(plan["steps"]), prompt
def test_teeth_intent_selects_face_then_whitens():
plan = guide.fallback_plan("fix my teeth")
assert _ops(plan["steps"]) == ["selection.face", "retouch.teeth"]
assert plan["steps"][1]["params"]["desat"] > 0
def test_background_intent_is_selection_then_masked_blur():
plan = guide.fallback_plan("blur the background / make the background nicer")
ops = _ops(plan["steps"])
assert ops == ["selection.background", "filters.applyMasked"]
assert plan["steps"][1]["params"]["id"] == "blur-gaussian"
def test_temperature_sign_is_cool_negative_warm_positive():
cool = guide.fallback_plan("make it moodier")
assert cool["steps"][0]["params"] == {
"type": "white-balance", "params": {"temperature": -25}}
warm = guide.fallback_plan("make it warmer, golden summer feel")
assert warm["steps"][0]["params"]["params"]["temperature"] == 25
def test_lighting_intent_uses_auto_tone_then_single_shadow_lift():
plan = guide.fallback_plan("fix the lighting, it's too dark")
assert _ops(plan["steps"]) == ["photo.autoTone", "adjustments.apply"]
inner = plan["steps"][1]["params"]
assert inner["type"] == "shadows-highlights"
assert inner["params"]["highlights"] <= 0
# The sanctioned low-key pair, never a stack of unspecified lifts.
assert len(plan["steps"]) == 2
def test_ambiguous_prompt_asks_the_user_with_concrete_options():
plan = guide.fallback_plan("clean this up")
assert plan["steps"] == []
ask = plan["ask_user"]
assert ask["question"]
assert 2 <= len(ask["options"]) <= 3
assert all(o.get("label") for o in ask["options"])
assert guide.is_ambiguous("clean this up") is True
# A concrete defect is not ambiguous.
assert guide.is_ambiguous("fix the lighting") is False
def test_collapse_global_lifts_replaces_a_stack():
# With an auto tone present, extra brightness/exposure lifts are dropped
# (the auto tone is the single sanctioned lift).
with_auto = [
{"op": "photo.autoTone", "params": {}},
{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
{"op": "adjustments.apply",
"params": {"type": "exposure", "params": {"exposure": 20}}},
]
out = guide.collapse_global_lifts(with_auto)
assert len(out) == 1
assert out[0]["op"] == "photo.autoTone"
# Without an auto tone, a stack collapses to one safe shadows-highlights.
no_auto = [
{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
{"op": "adjustments.apply",
"params": {"type": "exposure", "params": {"exposure": 20}}},
]
out2 = guide.collapse_global_lifts(no_auto)
assert len(out2) == 1
assert out2[0]["params"]["type"] == "shadows-highlights"
assert out2[0]["params"]["params"]["highlights"] <= 0
# The sanctioned low-key pair (auto tone + one shadows-highlights) is kept.
pair = [
{"op": "photo.autoTone", "params": {}},
{"op": "adjustments.apply",
"params": {"type": "shadows-highlights",
"params": {"shadows": 30, "highlights": -15}}},
]
assert guide.collapse_global_lifts(pair) == pair
def test_collapse_global_lifts_clamps_a_single_brightness_lift():
steps = [{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 80}}}]
out = guide.collapse_global_lifts(steps)
assert out[0]["params"]["params"]["brightness"] <= 25
def test_llm_plan_parse_collapses_lifts_and_keeps_new_ops():
raw = (
'{"reply": "x", "steps": ['
'{"op": "retouch.skin", "params": {"smooth": 20}},'
'{"op": "photo.autoTone", "params": {}},'
'{"op": "adjustments.apply", "params": {"type": "exposure", "params": {"exposure": 25}}}'
']}'
)
parsed = eaar._parse_plan(raw)
ops = _ops(parsed["steps"])
assert "retouch.skin" in ops
# Every global lift that trails retouch.skin is dropped (the client guard
# "never brighten skin"), so nothing but the retouch step remains.
assert ops == ["retouch.skin"]
def test_editor_agent_returns_ask_user_for_ambiguous_prompt(client):
eaar.set_editor_planner(lambda messages, owner: '{"reply": "ok", "steps": []}')
resp = client.post("/api/editor/agent", json={"prompt": "clean this up"})
assert resp.status_code == 200
body = resp.json()
assert body.get("ask_user_pending") is True
assert body["steps"] == []
assert 2 <= len(body["ask_user"]["options"]) <= 3
def test_editor_plan_returns_ask_user_for_ambiguous_prompt(client):
eaar.set_editor_planner(lambda messages, owner: None)
resp = client.post("/api/editor/plan", json={"prompt": "clean this up"})
assert resp.status_code == 200
body = resp.json()
assert body["steps"] == []
assert body.get("ask_user")
def test_style_guide_endpoint_serves_the_shared_source(client):
resp = client.get("/api/editor/style-guide")
assert resp.status_code == 200
data = resp.json()
assert data["version"] == guide.STYLE_GUIDE_VERSION
assert {o["op"] for o in data["new_ops"]} == set(guide.NEW_OP_NAMES)
assert data["intents"]
assert "temperature" in data["rules"]["temperature_sign"].lower() or \
"negative" in data["rules"]["temperature_sign"]
# The prompt guidance states the sign rule explicitly.
assert "NEGATIVE" in data["guidance"] and "POSITIVE" in data["guidance"]
def test_prompts_embed_the_style_guidance():
assert guide.guidance() in eaar._SYSTEM_PROMPT
assert guide.guidance() in eaar._CHECK_SYSTEM_PROMPT
@pytest.mark.parametrize("prompt", [
"make it pretty", "make it pop", "professional", "film look",
])
def test_generic_look_intent_is_a_decisive_multi_step_plan(prompt):
plan = guide.fallback_plan(prompt)
assert plan is not None
ops = _ops(plan["steps"])
assert ops[0] == "photo.autoTone"
assert len(ops) >= 3
# A visible vibrance lift and a local-contrast filter are present.
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "vibrance"
for s in plan["steps"])
assert any(s["op"] == "filters.apply" and s["params"].get("id") == "clarity"
for s in plan["steps"])
def test_restore_intent_never_large_exposure_lift():
plan = guide.fallback_plan("restore this old photo")
assert plan is not None
ops = _ops(plan["steps"])
assert ops[0] == "photo.autoTone"
assert "filters.apply" in ops # sharpen
# Colour recovery present.
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "white-balance"
for s in plan["steps"])
# No exposure lift at all, and the contrast lift keeps brightness neutral.
assert not any(s["params"].get("type") == "exposure" for s in plan["steps"])
for s in plan["steps"]:
if s["params"].get("type") == "brightness-contrast":
assert s["params"]["params"].get("brightness", 0) == 0
def test_strengthen_promotes_a_timid_plan_to_the_recipe():
timid = [{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}]
promoted = guide.strengthen("make it pop", timid)
assert promoted != timid
assert _ops(promoted)[0] == "photo.autoTone"
assert len(promoted) >= 3
# A prompt with no recipe leaves the plan untouched.
assert guide.strengthen("nonsense prompt zzz", timid) == timid
def test_enforce_guards_removes_trailing_lift_after_skin():
steps = [
{"op": "selection.face", "params": {}},
{"op": "retouch.skin", "params": {"smooth": 24}},
{"op": "photo.autoTone", "params": {}},
]
guarded = guide.enforce_guards(steps)
assert _ops(guarded) == ["selection.face", "retouch.skin"]
stacked = [
{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
{"op": "adjustments.apply",
"params": {"type": "exposure", "params": {"exposure": 20}}},
]
collapsed = guide.enforce_guards(stacked)
assert len(collapsed) == 1
assert collapsed[0]["params"]["type"] == "shadows-highlights"
def test_plan_route_follows_the_recipe_for_generic_look(client):
import json as _json
eaar.set_editor_planner(lambda messages, owner: _json.dumps({
"reply": "ok",
"steps": [{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}],
}))
resp = client.post("/api/editor/plan", json={"prompt": "make it pop"})
assert resp.status_code == 200
data = resp.json()
assert data["steps"][0]["op"] == "photo.autoTone"
assert len(data["steps"]) >= 3
assert data["planner"].endswith("+guide")
def test_guidance_documents_look_and_restore_recipes():
text = guide.guidance()
low = text.lower()
assert "clarity" in low and "vignette" in low
assert "vibrance" in low
assert "restore" in low and "exposure" in low
assert "autoTone" in text
def test_plan_special_format_crops_and_resizes():
special = eaar._plan_special(
"make a 1080x1350 Instagram post from this", {"width": 900, "height": 600}, False)
assert special is not None
steps, reply, note = special
assert _ops(steps) == ["document.crop", "document.resizeCanvas"]
assert "1080" in reply and "1350" in reply
assert set(steps[0]["params"]) >= {"x", "y", "width", "height"}
assert steps[1]["params"]["width"] == 1080 and steps[1]["params"]["height"] == 1350
def test_plan_special_text_add_with_shadow_is_not_generative():
assert eaar._generative_kind("add a title TOKYO with a drop shadow") is None
special = eaar._plan_special("add a title TOKYO with a drop shadow", {}, False)
assert special is not None
steps, reply, note = special
assert _ops(steps) == ["text.add", "layers.addStyle"]
assert steps[0]["params"]["text"] == "TOKYO"
assert steps[1]["params"]["type"] == "drop-shadow"
assert "drop shadow" in reply.lower()
def test_plan_special_extend_resizes_without_provider_generative_with():
steps, reply, note = eaar._plan_special(
"extend the canvas to 16:9", {"width": 900, "height": 600}, False)
assert _ops(steps) == ["document.resizeCanvas"]
assert "provider" in note.lower()
steps2, _, note2 = eaar._plan_special(
"extend the canvas to 16:9", {"width": 900, "height": 600}, True)
assert _ops(steps2) == ["ai.generativeExpand"]
assert note2 == ""
def test_plan_special_psd_export():
special = eaar._plan_special("save as PSD with layers", {}, False)
assert special is not None and _ops(special[0]) == ["document.savePsd"]
def test_background_replacement_is_generative_not_blur():
assert eaar._generative_kind("put her on a beach") == "replace"
assert eaar._generative_kind("remove the background and put her on a beach") == "replace"
def test_verified_reply_corrects_a_false_size_claim():
corrected = eaar._verified_reply(
"make a 1080x1350 Instagram post",
[{"op": "history.mark", "params": {}}],
[{"ok": True}],
{"width": 600, "height": 600},
"Exported your 900x600 as a 1080x1350 Instagram post.",
)
assert "600" in corrected
assert "still" in corrected.lower() or "didn't apply" in corrected.lower()
passed = eaar._verified_reply(
"make a 1080x1350 Instagram post",
[{"op": "document.crop", "params": {}}, {"op": "document.resizeCanvas", "params": {}}],
[{"ok": True}, {"ok": True}],
{"width": 1080, "height": 1350},
"Setting the canvas to 1080\u00d71350.",
)
assert "1080" in passed and "1350" in passed
def test_plan_endpoint_returns_deterministic_format_steps(client, monkeypatch):
eaar.set_editor_planner(lambda messages, owner: {"reply": "Exporting.", "steps": []})
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
resp = client.post("/api/editor/plan", json={
"prompt": "make a 1080x1350 Instagram post",
"state": {"width": 900, "height": 600},
})
data = resp.json()
assert data["planner"] == "deterministic"
assert _ops(data["steps"])[:2] == ["document.crop", "document.resizeCanvas"]
def test_style_guide_serves_format_text_psd_rules():
rules = guide.as_json()["rules"]
assert "format" in rules and "text" in rules and "psd" in rules
assert guide.STYLE_GUIDE_VERSION >= 3
# ── Quick Edit coverage: structural ops (server planner gaps) ──────────
# Every prompt that failed the live Quick Edit coverage audit because the
# server planner emitted a generic adjustments/transform step instead of the
# exact structural op the browser supports.
_STRUCTURAL_PROMPTS = [
("fill the white area with red", "fill.flood"), # 1
("flood fill the sky with blue", "fill.flood"), # 2
("make the text bigger", "text.edit"), # 3
("make the title red", "text.edit"), # 4
("add a colour overlay", "layers.addStyle"), # 8
("align the logo to the centre", "layers.align"), # 9
("align the layer to the left", "layers.align"), # 10
("rotate only this layer 90 degrees", "layer.rotate"), # 11
("flip this layer horizontally", "layer.flip"), # 12
("rotate the whole image 90 degrees", "document.rotate"), # 13
("flip the canvas vertically", "document.flip"), # 14
("copy the selection", "selection.copy"), # 19
("paste", "selection.paste"), # 20
("flatten the image", "layers.flatten"), # 40
("merge the layer down", "layers.mergeDown"), # 41
]
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
def test_structural_plan_routes_to_the_exact_op(prompt, expected_op):
plan = guide.structural_plan(prompt)
assert plan is not None, prompt
assert plan["planner"] == "deterministic"
assert expected_op in _ops(plan["steps"]), (prompt, _ops(plan["steps"]))
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
def test_plan_special_emits_structural_ops(prompt, expected_op):
# The server override path (_plan_special) must return the structural step,
# otherwise a wrong model plan suppresses the browser fallback.
special = eaar._plan_special(prompt, {"width": 900, "height": 600}, True)
assert special is not None, prompt
steps, reply, _ = special
assert expected_op in _ops(steps), (prompt, _ops(steps))
assert reply
def test_structural_ops_are_in_the_server_op_catalog():
for name in ("fill.flood", "selection.fill", "text.edit", "layers.align",
"layer.rotate", "layer.flip", "document.rotate", "document.flip",
"selection.copy", "selection.paste", "layers.flatten",
"layers.mergeDown"):
assert name in eaar._OP_SET, name
def test_structural_plan_ignores_non_structural_intents():
for prompt in ("make it brighter", "blur the background", "make it punchy",
"restore this old photo", "smooth the skin", "sharpen it",
"add the title \"Summer\"", "duplicate this layer"):
assert guide.structural_plan(prompt) is None, prompt
def test_structural_params_match_the_client():
# Canonical params must match static/js/editor/galleryEditor.js control ops.
fill = guide.structural_plan("fill the white area with red")["steps"][0]
assert fill["op"] == "fill.flood" and fill["params"]["color"] == "#ff0000"
sel = guide.structural_plan("fill the selection with blue")["steps"][0]
assert sel["op"] == "selection.fill" and sel["params"]["color"] == "#0000ff"
align = guide.structural_plan("align the layer to the left")["steps"][0]
assert align["params"]["mode"] == "left"
rot = guide.structural_plan("rotate only this layer 90 degrees")["steps"][0]
assert rot["params"]["degrees"] == 90
flip = guide.structural_plan("flip the canvas vertically")["steps"][0]
assert flip["op"] == "document.flip" and flip["params"]["axis"] == "v"
overlay = guide.structural_plan("add a colour overlay")["steps"][0]
assert overlay["params"]["type"] == "color-overlay"
edit = guide.structural_plan("make the text bigger")["steps"][0]
assert edit["op"] == "text.edit" and edit["params"]["size"] > 0
def test_plan_route_returns_structural_steps(client):
resp = client.post("/api/editor/plan", json={
"prompt": "flatten the image",
"state": {"width": 900, "height": 600},
})
data = resp.json()
assert data["planner"] == "deterministic"
assert "layers.flatten" in _ops(data["steps"])
def test_style_guide_serves_structural_rule():
rules = guide.as_json()["rules"]
assert "structural" in rules
assert guide.STYLE_GUIDE_VERSION >= 5
# ── Targeted recolour + named reference looks ──────────────────────────
_RECOLOR_CASES = [
("make only the red car blue", "red"),
("recolour the red rock to teal", "red"),
("change the yellow car to red", "yellow"),
]
@pytest.mark.parametrize("prompt,source", _RECOLOR_CASES)
def test_targeted_recolor_selects_object_then_masked_hue(prompt, source):
plan = guide.recolor_plan(prompt)
assert plan is not None
assert _ops(plan["steps"]) == ["selection.samText", "adjustments.applyMasked"]
sel, adj = plan["steps"]
assert sel["params"]["text"] # object description for the mask
assert sel["params"]["fallbackColor"] == guide.color_for_name(source)
assert adj["params"]["mode"] == "hue-saturation"
assert "hue" in adj["params"] and isinstance(adj["params"]["hue"], int)
def test_recolor_never_emits_selection_fill_or_a_global_tint():
for prompt, _ in _RECOLOR_CASES:
ops = _ops(guide.recolor_plan(prompt)["steps"])
# A flat fill destroys shading; a whole-image tint is not targeted.
assert "selection.fill" not in ops
assert "adjustments.apply" not in ops # exact op, not applyMasked
def test_targeted_recolor_hue_delta_is_source_to_target():
# red (hue 0) -> blue (hue 240): shortest rotation is -120 degrees.
plan = guide.recolor_plan("make only the red car blue")
assert plan["steps"][1]["params"]["hue"] == -120
def test_recolor_masked_hue_carries_source_hue():
# red has hue 0; the client uses sourceHue as the rotation center so
# neighbouring colours (chrome, windows, highlights) keep their own hue.
plan = guide.recolor_plan("make only the red car blue")
assert plan["steps"][1]["params"]["sourceHue"] == 0
def test_enforce_guards_drops_global_lift_after_masked_recolor():
# A recolour keeps shading; a later whole-image tone lift would tint the
# rest of the photo, so enforce_guards must drop global lifts after it.
steps = [
{"op": "selection.samText", "params": {"text": "red car"}},
{"op": "adjustments.applyMasked",
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
{"op": "photo.autoTone", "params": {}},
]
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
def test_enforce_guards_drops_whole_image_grade_after_masked_recolor():
# Not just tone "lifts": ANY whole-image adjustment (vibrance/saturation/
# autoTone) after a masked recolour re-grades the photo and undoes the
# "only this object" intent, so enforce_guards must drop them all.
steps = [
{"op": "selection.samText", "params": {"text": "red car"}},
{"op": "adjustments.applyMasked",
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
{"op": "adjustments.apply",
"params": {"type": "vibrance", "params": {"vibrance": 20}}},
{"op": "adjustments.apply",
"params": {"type": "hue-saturation", "params": {"saturation": 12}}},
{"op": "photo.autoTone", "params": {}},
]
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
def test_enforce_guards_keeps_grades_when_there_is_no_recolor():
# A recipe/look (no masked recolour) still keeps its whole-image grades.
steps = [
{"op": "photo.autoTone", "params": {}},
{"op": "adjustments.apply", "params": {"type": "vibrance", "params": {"vibrance": 18}}},
{"op": "adjustments.apply",
"params": {"type": "brightness-contrast", "params": {"brightness": 6, "contrast": -10}}},
]
assert _ops(guide.enforce_guards(steps)) == ["photo.autoTone", "adjustments.apply"]
def test_recolor_to_white_uses_the_image_model_not_a_hue_shift():
# A white/black/grey target cannot be reached by a hue rotation.
plan = guide.recolor_plan("make the red car white")
assert plan is not None
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
assert plan["steps"][1]["params"]["mode"] == "masked"
def test_recolor_with_single_colour_uses_masked_image_model():
plan = guide.recolor_plan("make the flowers bright pink")
assert plan is not None
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
assert plan["steps"][0]["params"]["text"] == "flowers"
params = plan["steps"][1]["params"]
assert params["mode"] == "masked"
assert params["steps"] == 8
def test_recolor_does_not_hijack_unrelated_colour_prompts():
for prompt in ("make it blue", "fill the white area with red",
"make the title red", "make this black and white",
"blur the background"):
assert guide.recolor_plan(prompt) is None
@pytest.mark.parametrize("prompt", [
"make it look like a Wes Anderson film",
"make it look like a film noir",
"make it look like vintage Kodachrome",
])
def test_named_recipe_looks_are_adjustment_stacks(prompt):
plan = guide.look_plan(prompt)
assert plan is not None
ops = _ops(plan["steps"])
assert ops and "ai_edit_image" not in ops
@pytest.mark.parametrize("prompt", [
"make it look like cyberpunk",
"make it look like a Studio Ghibli film",
])
def test_strong_named_looks_use_instruction_mode(prompt):
plan = guide.look_plan(prompt)
assert plan is not None
assert _ops(plan["steps"]) == ["ai_edit_image"]
assert plan["steps"][0]["params"]["mode"] == "instruction"
assert plan["steps"][0]["params"]["steps"] == 8
def test_structural_plan_dispatches_looks_and_recolor():
wes = guide.structural_plan("make it look like a Wes Anderson film")
assert "adjustments.apply" in _ops(wes["steps"])
recolor = guide.structural_plan("make only the red car blue")
assert _ops(recolor["steps"]) == ["selection.samText", "adjustments.applyMasked"]
def test_named_looks_do_not_hijack_generative_lookalikes():
for prompt in ("make it look snowy", "make it look like summer"):
assert guide.look_plan(prompt) is None
def test_plan_route_returns_targeted_recolor(client):
resp = client.post("/api/editor/plan", json={
"prompt": "make only the red car blue",
"state": {"width": 900, "height": 600},
})
assert resp.status_code == 200
data = resp.json()
assert data["planner"] == "deterministic"
assert _ops(data["steps"]) == ["selection.samText", "adjustments.applyMasked"]
assert "selection.fill" not in _ops(data["steps"])
def test_plan_route_returns_named_look(client):
resp = client.post("/api/editor/plan", json={
"prompt": "make it look like a Wes Anderson film",
"state": {"width": 900, "height": 600},
})
assert resp.status_code == 200
data = resp.json()
assert data["planner"] == "deterministic"
assert "adjustments.apply" in _ops(data["steps"])
def test_style_guide_serves_recolor_and_looks_rules():
body = guide.as_json()
assert "recolor" in body["rules"]
assert "looks" in body["rules"]
assert {lk["id"] for lk in body["looks"]} >= {
"wes_anderson", "film_noir", "cyberpunk", "vintage_kodachrome",
"studio_ghibli",
}
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))
+51
View File
@@ -0,0 +1,51 @@
"""Graceful-shutdown signalling tests (HARNESS-owned).
SSE / streaming responses never complete on their own, so uvicorn's graceful
shutdown would wait for an open browser stream up to the whole
``timeout_graceful_shutdown`` window. src.shutdown is a process-wide flag that
the SIGTERM/SIGINT handler flips and every streaming generator checks, so a
restart ends open streams promptly while ordinary in-flight requests still
finish.
"""
from pathlib import Path
from src import shutdown
ROOT = Path(__file__).resolve().parent.parent
def test_request_shutdown_is_sticky_and_resettable():
shutdown.reset()
try:
assert shutdown.is_shutting_down() is False
shutdown.request_shutdown()
assert shutdown.is_shutting_down() is True
# idempotent
shutdown.request_shutdown()
assert shutdown.is_shutting_down() is True
finally:
shutdown.reset()
assert shutdown.is_shutting_down() is False
def test_app_installs_its_own_shutdown_handler():
src = (ROOT / "app.py").read_text(encoding="utf-8")
# This uvicorn version has no install_signal_handlers kwarg, so app.py wraps
# server.handle_exit (which capture_signals wires SIGINT/SIGTERM to).
assert "install_signal_handlers=False" not in src
assert "server.handle_exit = _handle_shutdown_signal" in src
assert "_shutdown.request_shutdown()" in src
assert "server.run()" in src
def test_streaming_generators_check_the_shutdown_flag():
# editor bridge SSE + chat stream/resume + research + email events + shell.
for rel in (
"routes/editor_agent_routes.py",
"routes/chat_routes.py",
"routes/research/research_routes.py",
"routes/email/email_routes.py",
"routes/shell_routes.py",
):
src = (ROOT / rel).read_text(encoding="utf-8")
assert "_shutdown.is_shutting_down()" in src, rel