refactor(automations): remove phase-6 chat watch (watch service/routes + chat_message action)

This commit is contained in:
CREDO23 2026-07-03 11:20:22 +02:00
parent 5da624399d
commit c66b2f0e0e
17 changed files with 1 additions and 959 deletions

View file

@ -1,118 +0,0 @@
"""Integration tests for the ``chat_message`` action's concurrency guard.
The guard reads real ``ChatSessionState`` (seeded via the production
``set_ai_responding`` helper) to decide whether a scheduled watch tick may run.
This proves the skip decision against real persistence the part a fake
``get_session_state`` could silently get wrong.
The turn's actual work (``stream_new_chat``) resolves an LLM from the DB and
runs the agent; that end-to-end drain is a running-stack concern. Here it is
replaced by a spy so we can assert the guard's two outcomes: skip without
streaming when a turn is in flight, and stream (with system auth) when not.
"""
from __future__ import annotations
from typing import Any
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.actions.builtin.chat_message import invoke as invoke_mod
from app.automations.actions.types import ActionContext
from app.db import ChatVisibility, NewChatThread, User, Workspace
from app.services.chat_session_state_service import (
clear_ai_responding,
set_ai_responding,
)
pytestmark = pytest.mark.integration
@pytest_asyncio.fixture
async def thread(
db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
row = NewChatThread(
title="Watched chat",
workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
db_session.add(row)
await db_session.flush()
return row
def _ctx(session: AsyncSession, *, workspace_id: int, creator_id) -> ActionContext:
return ActionContext(
session=session,
run_id=1,
step_id="watch",
workspace_id=workspace_id,
creator_user_id=creator_id,
chat_model_id=1,
)
async def test_skips_without_streaming_when_a_turn_is_in_flight(
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
monkeypatch: pytest.MonkeyPatch,
) -> None:
await set_ai_responding(db_session, thread.id, db_user.id)
streamed = {"called": False}
async def _spy(**_kwargs: Any):
streamed["called"] = True
yield "data: x\n\n"
monkeypatch.setattr(invoke_mod, "stream_new_chat", _spy)
out = await invoke_mod.run_chat_message(
ctx=_ctx(db_session, workspace_id=db_workspace.id, creator_id=db_user.id),
thread_id=thread.id,
message="tick",
)
assert out["skipped"] == "in_flight"
assert out["frames"] == 0
assert streamed["called"] is False
async def test_streams_under_system_auth_when_no_turn_in_flight(
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Ensure the thread has no in-flight turn.
await clear_ai_responding(db_session, thread.id)
captured: dict[str, Any] = {}
async def _spy(**kwargs: Any):
captured.update(kwargs)
for frame in ("data: a\n\n", "data: b\n\n"):
yield frame
monkeypatch.setattr(invoke_mod, "stream_new_chat", _spy)
out = await invoke_mod.run_chat_message(
ctx=_ctx(db_session, workspace_id=db_workspace.id, creator_id=db_user.id),
thread_id=thread.id,
message="what changed?",
)
assert out["skipped"] is None
assert out["frames"] == 2
assert captured["chat_id"] == thread.id
assert captured["user_query"] == "what changed?"
assert captured["user_id"] == str(db_user.id)
auth = captured["auth_context"]
assert auth is not None and auth.method == "system" and auth.source == "automation"

View file

@ -1,136 +0,0 @@
"""Integration tests for the watch HTTP routes (real app, DB, auth).
Watches are seeded through the real service and read back through the mounted
endpoints, exercising query validation, auth, and response mapping. Run-now is
asserted without a broker by spying the Celery enqueue while still persisting a
real PENDING run.
"""
from __future__ import annotations
import httpx
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.automations.persistence.enums.run_status import RunStatus
from app.automations.persistence.models.run import AutomationRun
from app.automations.services.automation import AutomationService
from app.automations.services.chat_watch import create_watch
from app.db import ChatVisibility, NewChatThread, User, Workspace
pytestmark = pytest.mark.integration
BASE = "/api/v1/automations"
_CRON = "0 9 * * 1-5"
_TZ = "UTC"
def _billable(workspace: Workspace) -> None:
workspace.chat_model_id = 1
workspace.image_gen_model_id = 1
workspace.vision_model_id = 1
@pytest_asyncio.fixture
async def thread(
db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
row = NewChatThread(
title="Watched chat",
workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
db_session.add(row)
await db_session.flush()
return row
async def _seed_watch(
db_session: AsyncSession,
db_user: User,
*,
workspace_id: int,
thread_id: int,
):
service = AutomationService(session=db_session, auth=AuthContext.session(db_user))
return await create_watch(
service,
workspace_id=workspace_id,
thread_id=thread_id,
message="what changed?",
cron=_CRON,
timezone=_TZ,
)
async def test_list_watches_returns_the_threads_watch(
client: httpx.AsyncClient,
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
watch = await _seed_watch(
db_session, db_user, workspace_id=db_workspace.id, thread_id=thread.id
)
resp = await client.get(
f"{BASE}/watches",
params={"workspace_id": db_workspace.id, "thread_id": thread.id},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["id"] == watch.id
async def test_list_watches_empty_when_thread_not_watched(
client: httpx.AsyncClient,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
resp = await client.get(
f"{BASE}/watches",
params={"workspace_id": db_workspace.id, "thread_id": thread.id},
)
assert resp.status_code == 200
assert resp.json()["total"] == 0
async def test_run_watch_now_enqueues_and_persists_pending_run(
client: httpx.AsyncClient,
enqueue_spy: list[dict],
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
watch = await _seed_watch(
db_session, db_user, workspace_id=db_workspace.id, thread_id=thread.id
)
resp = await client.post(f"{BASE}/{watch.id}/run")
assert resp.status_code == 202
assert len(enqueue_spy) == 1
runs = (
(
await db_session.execute(
select(AutomationRun).where(AutomationRun.automation_id == watch.id)
)
)
.scalars()
.all()
)
assert len(runs) == 1
assert runs[0].status == RunStatus.PENDING

View file

@ -5,9 +5,6 @@ behavior runs against real Postgres and rolls back at test end:
* ``client`` httpx over ASGI with ``get_async_session``/``get_auth_context``
overridden to the test session + owner.
* ``tools_use_test_session`` the watch tools open their own
``async_session_maker()``; repoint it at ``db_session`` so they join the
test transaction (and can see the fixture's uncommitted membership/models).
* ``enqueue_spy`` capture ``automation_run_execute.apply_async`` so run-now
can be asserted without a Redis broker.
"""
@ -15,7 +12,6 @@ behavior runs against real Postgres and rolls back at test end:
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import httpx
import pytest
@ -55,24 +51,6 @@ async def client(
app.dependency_overrides.update(previous)
@pytest.fixture
def tools_use_test_session(monkeypatch, db_session: AsyncSession) -> None:
"""Make the watch tools' ``async_session_maker()`` yield the test session."""
@asynccontextmanager
async def _session_cm():
yield db_session # owned by the outer fixture; do not close
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
refresh_watch,
start_watch,
stop_watch,
)
for module in (start_watch, stop_watch, refresh_watch):
monkeypatch.setattr(module, "async_session_maker", _session_cm)
@pytest.fixture
def enqueue_spy(monkeypatch) -> list[dict]:
"""Capture Celery enqueues so run-now needs no broker."""

View file

@ -1,217 +0,0 @@
"""Integration tests for the chat watch service against real Postgres.
A watch is an automation bound to a chat. These exercise the real
``AutomationService`` (RBAC, the automation model-billing gate, and the
definition JSON round-trip) rather than a fake, so the behaviors the unit
layer can't see are proven end to end:
* creating a watch persists a ``schedule`` + ``chat_message`` automation and
it reads back as the thread's watch;
* the billing gate rejects a watch when the workspace's models aren't
billable (Auto/free) the exact failure a fake service would hide;
* finding watches is thread-scoped; stopping one deletes it;
* run-now refuses an automation that has no schedule trigger.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.services.automation import AutomationService
from app.automations.services.chat_watch import (
create_watch,
find_watches_for_thread,
run_watch_now,
stop_watch,
)
from app.db import ChatVisibility, NewChatThread, User, Workspace
pytestmark = pytest.mark.integration
_CRON = "0 9 * * 1-5"
_TZ = "UTC"
def _billable(workspace: Workspace) -> None:
"""Give the workspace BYOK (positive id) models so automations are billable."""
workspace.chat_model_id = 1
workspace.image_gen_model_id = 1
workspace.vision_model_id = 1
@pytest_asyncio.fixture
async def service(db_session: AsyncSession, db_user: User) -> AutomationService:
return AutomationService(session=db_session, auth=AuthContext.session(db_user))
@pytest_asyncio.fixture
async def thread(
db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
row = NewChatThread(
title="Watched chat",
workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
db_session.add(row)
await db_session.flush()
return row
async def test_create_watch_persists_and_reads_back_as_the_threads_watch(
service: AutomationService,
db_session: AsyncSession,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
thread_id = thread.id
created = await create_watch(
service,
workspace_id=db_workspace.id,
thread_id=thread_id,
message="what changed on the pricing page?",
cron=_CRON,
timezone=_TZ,
)
assert created.id is not None
plan = created.definition["plan"]
assert len(plan) == 1
assert plan[0]["action"] == "chat_message"
assert plan[0]["params"] == {
"thread_id": thread_id,
"message": "what changed on the pricing page?",
}
schedule = [t for t in created.triggers if t.type == TriggerType.SCHEDULE]
assert len(schedule) == 1
assert schedule[0].params["cron"] == _CRON
found = await find_watches_for_thread(
service, workspace_id=db_workspace.id, thread_id=thread_id
)
assert [a.id for a in found] == [created.id]
async def test_create_watch_rejected_when_workspace_models_not_billable(
service: AutomationService,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
# Default workspace has no model prefs (Auto/None) -> not billable.
with pytest.raises(HTTPException) as exc:
await create_watch(
service,
workspace_id=db_workspace.id,
thread_id=thread.id,
message="track it",
cron=_CRON,
timezone=_TZ,
)
assert exc.value.status_code == 422
async def test_find_watches_is_scoped_to_the_thread(
service: AutomationService,
db_session: AsyncSession,
db_workspace: Workspace,
db_user: User,
) -> None:
_billable(db_workspace)
await db_session.flush()
thread_a = NewChatThread(
title="A", workspace_id=db_workspace.id, created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
thread_b = NewChatThread(
title="B", workspace_id=db_workspace.id, created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
db_session.add_all([thread_a, thread_b])
await db_session.flush()
watch_a = await create_watch(
service, workspace_id=db_workspace.id, thread_id=thread_a.id,
message="a", cron=_CRON, timezone=_TZ,
)
await create_watch(
service, workspace_id=db_workspace.id, thread_id=thread_b.id,
message="b", cron=_CRON, timezone=_TZ,
)
found = await find_watches_for_thread(
service, workspace_id=db_workspace.id, thread_id=thread_a.id
)
assert [a.id for a in found] == [watch_a.id]
async def test_stop_watch_deletes_and_leaves_the_thread_unwatched(
service: AutomationService,
db_session: AsyncSession,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
thread_id = thread.id
created = await create_watch(
service, workspace_id=db_workspace.id, thread_id=thread_id,
message="m", cron=_CRON, timezone=_TZ,
)
await stop_watch(service, automation_id=created.id)
found = await find_watches_for_thread(
service, workspace_id=db_workspace.id, thread_id=thread_id
)
assert found == []
with pytest.raises(HTTPException) as exc:
await service.get(created.id)
assert exc.value.status_code == 404
async def test_run_watch_now_rejects_automation_without_schedule_trigger(
service: AutomationService,
db_session: AsyncSession,
db_workspace: Workspace,
db_user: User,
thread: NewChatThread,
) -> None:
# Seed a watch-shaped automation whose only trigger is non-schedule.
automation = Automation(
workspace_id=db_workspace.id,
created_by_user_id=db_user.id,
name="Watch: broken",
definition={
"name": "Watch: broken",
"plan": [
{
"step_id": "watch",
"action": "chat_message",
"params": {"thread_id": thread.id, "message": "m"},
}
],
},
version=1,
)
automation.triggers.append(
AutomationTrigger(type=TriggerType.EVENT, params={}, enabled=True)
)
db_session.add(automation)
await db_session.flush()
with pytest.raises(HTTPException) as exc:
await run_watch_now(service, automation_id=automation.id)
assert exc.value.status_code == 422

View file

@ -1,48 +0,0 @@
"""Pure-logic unit tests for the ``chat_message`` action.
Only the infra-free contract lives here: the action self-registers, and its
params model requires ``thread_id`` + ``message`` and forbids extras. The
run-time behavior (the in-flight guard against real ``ChatSessionState`` and
streaming under system auth) is proven in
``tests/integration/automations/actions/builtin/chat_message/test_chat_message.py``,
against real persistence rather than a faked ``get_session_state``/``stream_new_chat``.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.unit
def test_chat_message_action_is_registered_after_package_import() -> None:
import app.automations # noqa: F401 (force side-effect registrations)
from app.automations.actions.store import get_action
definition = get_action("chat_message")
assert definition is not None
assert definition.type == "chat_message"
def test_params_require_thread_id_and_message_and_forbid_extra() -> None:
from pydantic import ValidationError
from app.automations.actions.builtin.chat_message.params import (
ChatMessageActionParams,
)
ok = ChatMessageActionParams.model_validate({"thread_id": 7, "message": "hi"})
assert ok.thread_id == 7
assert ok.message == "hi"
with pytest.raises(ValidationError):
ChatMessageActionParams.model_validate({"thread_id": 7, "message": ""})
with pytest.raises(ValidationError):
ChatMessageActionParams.model_validate({"thread_id": 7})
with pytest.raises(ValidationError):
ChatMessageActionParams.model_validate(
{"thread_id": 7, "message": "hi", "surprise": True}
)

View file

@ -1,75 +0,0 @@
"""Pure-logic unit tests for the chat watch service.
Only the deterministic, infra-free pieces live here: the plan predicate that
decides whether an automation watches a thread, and the schedule-trigger
picker. Everything that touches ``AutomationService``/Postgres (create, find,
stop, run-now) is proven against real infra in
``tests/integration/automations/services/test_chat_watch.py`` faking the
service we own would only assert wiring and hide the model-billing gate,
RBAC, and JSON round-trip that those calls really go through.
"""
from __future__ import annotations
from typing import Any
import pytest
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.services.chat_watch import (
WATCH_ACTION_TYPE,
plan_targets_thread,
schedule_trigger,
)
pytestmark = pytest.mark.unit
def _definition_with_step(action: str, params: dict[str, Any]) -> dict[str, Any]:
return {"plan": [{"step_id": "s", "action": action, "params": params}]}
def test_plan_targets_thread_true_for_matching_chat_message_step() -> None:
definition = _definition_with_step("chat_message", {"thread_id": 55, "message": "x"})
assert plan_targets_thread(definition, 55) is True
assert WATCH_ACTION_TYPE == "chat_message"
def test_plan_targets_thread_false_for_other_thread_or_action() -> None:
assert (
plan_targets_thread(
_definition_with_step("chat_message", {"thread_id": 999}), 55
)
is False
)
assert (
plan_targets_thread(
_definition_with_step("agent_task", {"thread_id": 55}), 55
)
is False
)
assert plan_targets_thread({}, 55) is False
assert plan_targets_thread(None, 55) is False
class _Automation:
"""Minimal stand-in carrying only the ``triggers`` list the picker reads."""
def __init__(self, triggers: list[AutomationTrigger]) -> None:
self.triggers = triggers
def _trigger(type_: TriggerType) -> AutomationTrigger:
return AutomationTrigger(type=type_, params={}, enabled=True)
def test_schedule_trigger_picks_the_schedule_row() -> None:
schedule = _trigger(TriggerType.SCHEDULE)
automation = _Automation([_trigger(TriggerType.EVENT), schedule])
assert schedule_trigger(automation) is schedule
def test_schedule_trigger_returns_none_without_a_schedule() -> None:
automation = _Automation([_trigger(TriggerType.EVENT)])
assert schedule_trigger(automation) is None