mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
Merge branch 'sync/pr-6738-dev-20261026' into review/7011-with-dev-20261026
# Conflicts: # static/index.html # static/js/chat.js # static/sw.js
This commit is contained in:
+13
-10
@@ -25430,16 +25430,19 @@ async def stream_agent_loop(
|
||||
if schema.get("function", {}).get("name") not in disabled_tools
|
||||
and schema.get("name") not in disabled_tools
|
||||
]
|
||||
if _pure_web_turn and tool_surface != "full" and not (route_state.get("spec_surface") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"):
|
||||
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
||||
schemas = [
|
||||
schema for schema in schemas
|
||||
if (
|
||||
schema.get("function", {}).get("name")
|
||||
or schema.get("name")
|
||||
) in allowed
|
||||
or schema.get("function", {}).get("name") in external_names
|
||||
]
|
||||
if _pure_web_turn and tool_surface != "full":
|
||||
strict_spec = (route_state.get("spec_surface") and
|
||||
os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict")
|
||||
if not strict_spec:
|
||||
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
||||
schemas = [
|
||||
schema for schema in schemas
|
||||
if (
|
||||
schema.get("function", {}).get("name")
|
||||
or schema.get("name")
|
||||
) in allowed
|
||||
or schema.get("function", {}).get("name") in external_names
|
||||
]
|
||||
schemas = _drop_legacy_email_alias_schemas_when_mcp_available(schemas)
|
||||
schemas = _apply_tool_surface_to_schemas(schemas, tool_surface)
|
||||
if (
|
||||
|
||||
@@ -634,8 +634,16 @@ async def maybe_compact(
|
||||
)
|
||||
|
||||
# Use utility model if configured, otherwise fall back to session model
|
||||
# Compact with the chat model itself (the one being evaluated), never a separate utility model.
|
||||
compact_url, compact_model, compact_headers = endpoint_url, model, headers
|
||||
util_url, util_model, util_headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=endpoint_url,
|
||||
fallback_model=model,
|
||||
fallback_headers=headers,
|
||||
owner=owner,
|
||||
)
|
||||
compact_url = util_url or endpoint_url
|
||||
compact_model = util_model or model
|
||||
compact_headers = util_headers if util_url else headers
|
||||
|
||||
prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace(
|
||||
"{count}", str(len(older))
|
||||
|
||||
+1
-1
@@ -2687,7 +2687,7 @@
|
||||
<script type="module" src="/static/js/gallery.js?v=20260910promptcopy1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260914metricssummary1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js?v=20260831richtexttools91"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20261026devsync1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260917toolttft1"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import Storage from './storage.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import chatRenderer, { renderToolIcon } from './chatRenderer.js?v=20260914metricssummary1';
|
||||
import chatStream from './chatStream.js';
|
||||
import chatStream from './chatStream.js?v=20261026devsync1';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ const PRECACHE = [
|
||||
'/static/js/gallery.js?v=20260910promptcopy1',
|
||||
'/static/js/chatRenderer.js?v=20260914metricssummary1',
|
||||
'/static/js/codeRunner.js?v=20260831richtexttools91',
|
||||
'/static/js/chatStream.js',
|
||||
'/static/js/chatStream.js?v=20261026devsync1',
|
||||
'/static/js/chat.js?v=20260917toolttft1',
|
||||
'/static/js/cookbook.js',
|
||||
'/static/js/search-chat.js',
|
||||
|
||||
@@ -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"]))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user