Files
dify/api/tests/unit_tests/services/dify_builder/test_service.py
T

1903 lines
72 KiB
Python

"""Unit tests for the ``DifyBuilderService`` usecase (P3b Task 3).
Wires ``SqlDifyBuilderRepository`` (backed by a SQLite in-memory DB, same
fixtures as ``test_engine_on_sql_repo.py``) with a fake in-memory
``session_lock`` and a capturing ``enqueue_fn`` -- the service is
dependency-injected so it stays unit-testable without Celery or the real
Redis-backed ``session_lock`` module.
"""
import json
from collections.abc import Iterator
import pytest
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from core.dify_builder.contract import ActionKind
from core.dify_builder.errors import BadRequestError, BusyError, ConflictError, NotFoundError
from core.dify_builder.models import (
Action,
Actor,
ChecklistError,
ConversationItem,
DifyBuilderContext,
EntryMode,
Session,
)
from core.dify_builder.state import PcState
from models.base import Base
from services.dify_builder import service as service_module
from services.dify_builder.repository import SqlDifyBuilderRepository
from services.dify_builder.service import AppAccess, DifyBuilderService, resolve_action_kind
TENANT_ID = "11111111-1111-1111-1111-111111111111"
APP_ID = "22222222-2222-2222-2222-222222222222"
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
OTHER_ACCOUNT_ID = "44444444-4444-4444-4444-444444444444"
OTHER_TENANT_ID = "55555555-5555-5555-5555-555555555555"
class FakeSessionLock:
"""In-memory stand-in for the Redis-backed ``session_lock`` module."""
def __init__(self) -> None:
self._held: dict[str, str] = {}
def acquire(self, session_id: str) -> str | None:
if session_id in self._held:
return None
token = f"tok-{session_id}"
self._held[session_id] = token
return token
def release(self, session_id: str, token: str) -> None:
if self._held.get(session_id) == token:
self._held.pop(session_id, None)
def exists(self, session_id: str) -> bool:
return session_id in self._held
@pytest.fixture
def engine() -> Iterator[Engine]:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
yield engine
finally:
engine.dispose()
@pytest.fixture(autouse=True)
def configured_model(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
service_module,
"validate_model_config",
lambda _tenant, config: (
config or {"provider": "openai", "name": "gpt-4o", "mode": "chat", "completion_params": {}}
),
)
@pytest.fixture
def repo(engine: Engine) -> SqlDifyBuilderRepository:
factory = sessionmaker(bind=engine, expire_on_commit=False)
return SqlDifyBuilderRepository(factory)
@pytest.fixture
def lock() -> FakeSessionLock:
return FakeSessionLock()
@pytest.fixture
def enqueued() -> list[tuple]:
return []
@pytest.fixture
def service(repo: SqlDifyBuilderRepository, lock: FakeSessionLock, enqueued: list[tuple]) -> DifyBuilderService:
def enqueue_fn(session_id, action, actor, token) -> None:
enqueued.append((session_id, action, actor, token))
return DifyBuilderService(repo=repo, session_lock=lock, enqueue_fn=enqueue_fn)
@pytest.fixture
def inmemory_service_factory_raw(repo: SqlDifyBuilderRepository, lock: FakeSessionLock):
"""Build a ``DifyBuilderService`` (in-memory SQLite repo + fake lock) with
caller-supplied ``subscribe_fn``/``enqueue_fn``, for tests that need to
observe subscribe/dispatch ordering directly (rather than via the
capturing ``enqueued`` list the plain ``service`` fixture uses)."""
def _factory(subscribe_fn=None, enqueue_fn=None) -> tuple[DifyBuilderService, Actor]:
svc = DifyBuilderService(
repo=repo,
session_lock=lock,
enqueue_fn=enqueue_fn or (lambda *_a, **_k: None),
subscribe_fn=subscribe_fn,
)
return svc, _actor()
return _factory
class _StateSub:
"""Fake subscription: one terminal ``state`` frame, then closes."""
def receive(self, timeout=None): # noqa: ARG002
return json.dumps({"kind": "state", "version": 2, "session_id": "sid"}).encode()
def close(self):
pass
def _business_event(frame: str) -> str:
assert frame.startswith("event: message\n")
return json.loads(frame.split("data: ", 1)[1])["event"]
def _actor(account_id: str = ACCOUNT_ID) -> Actor:
return Actor(account_id=account_id, tenant_id=TENANT_ID)
def test_create_fix_session_dispatches_request_fix_and_holds_lock(
service: DifyBuilderService, lock: FakeSessionLock, enqueued: list[tuple]
) -> None:
actor = _actor()
view = service.create_fix_session(APP_ID, actor, failed_run_id="TR-1")
assert view.state == "fix.diagnose"
assert view.canvas_read_only is True
assert view.run_status == "processing"
assert view.app_id == APP_ID
assert view.version == 1
# the lock was acquired by dispatch and never released (enqueue_fn is a
# pure capture -- it does not run the task).
assert lock.exists(view.session_id) is True
assert len(enqueued) == 1
sid, action, dispatched_actor, token = enqueued[0]
assert sid == view.session_id
assert action == Action(kind="request_fix", base_version=1)
assert dispatched_actor == actor
assert token == f"tok-{view.session_id}"
def test_create_fix_session_records_failed_run(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
"""The bridge: ``failed_run_id`` is a Dify workflow-run id, which
create_fix_session records as an immutable ``original-failed``
``DifyBuilderRun``; ``fc.failed_run_id`` points at that row so the async
diagnose can resolve ``run.dify_run_id`` -> ``dify.node_outputs``."""
view = service.create_fix_session(APP_ID, _actor(), failed_run_id=" dify-run-abc ")
_session, fc = repo.get_session(view.session_id)
assert fc.failed_run_id # a DifyBuilderRun id...
assert fc.failed_run_id != "dify-run-abc" # ...not the Dify run id itself
recorded = repo.get_run(fc.failed_run_id)
assert recorded.kind == "original-failed"
assert recorded.dify_run_id == "dify-run-abc" # the Dify run the caller passed
assert recorded.status == "failed"
assert recorded.immutable is True
def test_create_checklist_session_records_no_failed_run(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
"""Checklist entry has no failed run: ``fc.failed_run_id`` stays empty."""
view = service.create_fix_session(
APP_ID, _actor(), checklist_errors=[ChecklistError(node_id="n1", node_type="llm", title="x")]
)
_session, fc = repo.get_session(view.session_id)
assert fc.failed_run_id == ""
def test_create_fix_session_checklist_entry(service: DifyBuilderService) -> None:
actor = _actor()
errors = [ChecklistError(node_id="n1", node_type="llm", title="LLM", messages=["missing prompt"])]
view = service.create_fix_session(APP_ID, actor, checklist_errors=errors)
assert view.state == "checklist.diagnose"
assert view.canvas_read_only is True
@pytest.mark.parametrize(
("failed_run_id", "checklist_errors"),
[
(None, None),
("", []),
(" ", None),
],
)
def test_create_fix_session_requires_failed_run_or_checklist_errors(
service: DifyBuilderService,
enqueued: list[tuple],
failed_run_id,
checklist_errors,
) -> None:
with pytest.raises(BadRequestError, match="failed_run_id or checklist_errors is required"):
service.create_fix_session(
APP_ID,
_actor(),
failed_run_id=failed_run_id,
checklist_errors=checklist_errors,
)
assert enqueued == []
def test_create_build_session_rejects_blank_goal(service: DifyBuilderService, enqueued: list[tuple]) -> None:
with pytest.raises(BadRequestError, match="goal_text is required"):
service.create_build_session(APP_ID, _actor(), goal_text=" ")
assert enqueued == []
@pytest.mark.parametrize(("derive", "expected"), [(True, True), (False, False)])
def test_create_build_session_records_whether_builder_may_rename_the_app(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, derive: bool, expected: bool
) -> None:
# The client knows which entry it came from: the homepage prompt (Builder
# renames once) or create-from-blank (the user already named it).
view = service.create_build_session(APP_ID, _actor(), goal_text="Build it", derive_app_name=derive)
_session, fc = repo.get_session(view.session_id)
assert fc.app_name_auto is expected
def test_create_build_session_records_the_apps_current_name(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock, enqueued: list[tuple]
) -> None:
# Seeded once at creation so the build-complete card can name the app.
svc = DifyBuilderService(
repo,
lock,
lambda *args: enqueued.append(args),
get_app_name_fn=lambda _app_id, _actor: "Refund approval",
)
view = svc.create_build_session(APP_ID, _actor(), goal_text="Build it")
_session, fc = repo.get_session(view.session_id)
assert fc.app_name == "Refund approval"
def test_create_build_session_tolerates_an_unreadable_app_name(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock, enqueued: list[tuple]
) -> None:
svc = DifyBuilderService(
repo,
lock,
lambda *args: enqueued.append(args),
get_app_name_fn=lambda _app_id, _actor: None,
)
view = svc.create_build_session(APP_ID, _actor(), goal_text="Build it")
_session, fc = repo.get_session(view.session_id)
assert fc.app_name == ""
def test_create_build_session_does_not_rename_by_default(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
view = service.create_build_session(APP_ID, _actor(), goal_text="Build it")
_session, fc = repo.get_session(view.session_id)
assert fc.app_name_auto is False
def test_create_fix_session_converts_valid_checklist_dicts_before_persisting(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
view = service.create_fix_session(
APP_ID,
_actor(),
checklist_errors=[
{
"node_id": "n1",
"node_type": "llm",
"title": "Missing prompt",
"messages": ["prompt is required"],
"unconnected": False,
"plugin_missing": False,
}
],
)
_session, context = repo.get_session(view.session_id)
assert context.checklist_errors == [
ChecklistError(
node_id="n1",
node_type="llm",
title="Missing prompt",
messages=["prompt is required"],
unconnected=False,
plugin_missing=False,
)
]
@pytest.mark.parametrize(
"entry",
[
"not-an-object",
{},
{
"node_id": "n1",
"node_type": "llm",
"title": "x",
"messages": "not-a-list",
"unconnected": False,
"plugin_missing": False,
},
{
"node_id": "n1",
"node_type": "llm",
"title": "x",
"messages": [1],
"unconnected": False,
"plugin_missing": False,
},
{
"node_id": "n1",
"node_type": "llm",
"title": "x",
"messages": [],
"unconnected": 0,
"plugin_missing": False,
},
{
"node_id": "n1",
"node_type": "llm",
"title": "x",
"messages": [],
"unconnected": False,
"plugin_missing": False,
"extra": "field",
},
],
)
def test_create_fix_session_rejects_malformed_checklist_items_before_persisting(
service: DifyBuilderService, enqueued: list[tuple], entry
) -> None:
with pytest.raises(BadRequestError, match="invalid checklist_errors item"):
service.create_fix_session(APP_ID, _actor(), checklist_errors=[entry])
assert enqueued == []
@pytest.mark.parametrize("app_id", ["", " ", None, 1])
def test_create_rejects_invalid_app_id_before_persisting(
service: DifyBuilderService, enqueued: list[tuple], app_id
) -> None:
with pytest.raises(BadRequestError, match="app_id is required"):
service.create_build_session(app_id, _actor(), goal_text="Build it")
assert enqueued == []
def test_create_authorizes_app_before_persisting(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock, enqueued: list[tuple]
) -> None:
calls: list[tuple[Actor, str, AppAccess]] = []
def deny(actor: Actor, app_id: str, access: AppAccess) -> None:
calls.append((actor, app_id, access))
raise NotFoundError("app not found")
svc = DifyBuilderService(repo, lock, lambda *args: enqueued.append(args), authorize_app_fn=deny)
with pytest.raises(NotFoundError, match="app not found"):
svc.create_build_session(f" {APP_ID} ", _actor(), goal_text="Build it")
assert calls == [(_actor(), APP_ID, AppAccess.EDIT)]
assert enqueued == []
def test_submit_action_while_lock_held_raises_busy(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
lock: FakeSessionLock,
enqueued: list[tuple],
) -> None:
actor = _actor()
session = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
assert lock.acquire(session.id) is not None
# Validation passes, but dispatch's acquire fails because the lock is held.
with pytest.raises(BusyError):
service.submit_action(session.id, actor, Action(kind="run_verify", base_version=1))
assert enqueued == []
def test_submit_action_with_stale_base_version_raises_conflict(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, enqueued: list[tuple]
) -> None:
actor = _actor()
session = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
with pytest.raises(ConflictError):
service.submit_action(session.id, actor, Action(kind="run_verify", base_version=999))
assert enqueued == []
def test_get_session_view_by_non_owner_raises_not_found(service: DifyBuilderService) -> None:
actor = _actor()
other = _actor(OTHER_ACCOUNT_ID)
view = service.create_fix_session(APP_ID, actor, failed_run_id="TR-1")
with pytest.raises(NotFoundError):
service.get_session_view(view.session_id, other)
def test_submit_action_by_non_owner_raises_not_found(service: DifyBuilderService) -> None:
actor = _actor()
other = _actor(OTHER_ACCOUNT_ID)
view = service.create_fix_session(APP_ID, actor, failed_run_id="TR-1")
with pytest.raises(NotFoundError):
service.submit_action(view.session_id, other, Action(kind="run_verify", base_version=1))
def test_session_access_requires_matching_owner_and_tenant(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock
) -> None:
session = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.FIX,
current_state=PcState.FIX_AWAIT_VERIFY,
)
repo.create_session(session, DifyBuilderContext(), [])
authorizations: list[tuple] = []
svc = DifyBuilderService(
repo,
lock,
lambda *_args: None,
authorize_app_fn=lambda *args: authorizations.append(args),
)
wrong_tenant_actor = Actor(account_id=ACCOUNT_ID, tenant_id=OTHER_TENANT_ID)
with pytest.raises(NotFoundError, match="session not found"):
svc.get_session_view(session.id, wrong_tenant_actor)
with pytest.raises(NotFoundError, match="session not found"):
svc.submit_action(session.id, wrong_tenant_actor, Action(kind="run_verify", base_version=1))
assert authorizations == []
def test_get_session_view_interrupted_reflects_lock_absence(
service: DifyBuilderService, lock: FakeSessionLock, enqueued: list[tuple]
) -> None:
actor = _actor()
view = service.create_fix_session(APP_ID, actor, failed_run_id="TR-1")
sid, _action, _actor2, token = enqueued[0]
# lock is held (dispatch's acquire succeeded) -- fix.diagnose is a
# working state, so with the lock held it must NOT be interrupted.
view = service.get_session_view(sid, actor)
assert view.state == "fix.diagnose"
assert view.interrupted is False
# release the lock out-of-band (simulating a crashed/expired worker) --
# the working state now reads as interrupted.
lock.release(sid, token)
view = service.get_session_view(sid, actor)
assert view.interrupted is True
def test_waiting_session_projects_executing_while_worker_lock_is_held(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
lock: FakeSessionLock,
) -> None:
session = _seed_session_at(repo, PcState.FIX_AWAIT_APPROVAL)
token = lock.acquire(session.id)
assert token is not None
locked_view = service.get_session_view(session.id, _actor())
assert locked_view.run_status == "processing"
assert locked_view.canvas_read_only is True
lock.release(session.id, token)
settled_view = service.get_session_view(session.id, _actor())
assert settled_view.run_status == "waiting_confirmation"
assert settled_view.canvas_read_only is False
def test_app_revision_is_projected_and_guards_workflow_dependent_actions(
repo: SqlDifyBuilderRepository,
lock: FakeSessionLock,
) -> None:
current_revision = "hash-1"
enqueued: list[Action] = []
svc = DifyBuilderService(
repo,
lock,
lambda _sid, action, _actor, _token: enqueued.append(action),
get_app_revision_fn=lambda _app_id, _actor: current_revision,
)
session = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
stored, context = repo.get_session(session.id)
context.last_snapshot_hash = "hash-1"
repo.compare_and_advance(session.id, stored.version, stored.current_state, context, [])
view = svc.get_session_view(session.id, _actor())
assert view.app_revision is not None
assert view.app_revision.observed == "hash-1"
assert view.app_revision.current == "hash-1"
assert view.app_revision.conflicted is False
current_revision = "hash-2"
view = svc.get_session_view(session.id, _actor())
assert view.app_revision is not None
assert view.app_revision.conflicted is True
assert [action.id for action in view.actions] == ["check_recovery"]
with pytest.raises(BadRequestError, match="base_app_revision is required"):
svc.submit_action(session.id, _actor(), Action(kind="run_verify", base_version=2))
with pytest.raises(ConflictError, match="stale app revision"):
svc.submit_action(
session.id,
_actor(),
Action(kind="run_verify", base_version=2, base_app_revision="hash-1"),
)
with pytest.raises(ConflictError, match="draft changed outside Builder"):
svc.submit_action(
session.id,
_actor(),
Action(kind="run_verify", base_version=2, base_app_revision="hash-2"),
)
with pytest.raises(ConflictError, match="draft changed outside Builder"):
svc.submit_message(session.id, _actor(), "What changed?", base_version=2, client_turn_id="turn-1")
with pytest.raises(ConflictError, match="draft changed outside Builder"):
svc.submit_action(
session.id,
_actor(),
Action(kind="update_model", payload={"model_config": {"name": "test"}}, base_version=2),
)
svc.submit_action(
session.id,
_actor(),
Action(kind="check_recovery", base_version=2, base_app_revision="hash-2"),
)
assert len(enqueued) == 1
def _drifted_testdata_session(repo, lock, enqueued):
"""A testdata gate whose draft moved after the form was issued."""
revision = {"value": "hash-1"}
svc = DifyBuilderService(
repo,
lock,
lambda _sid, action, _actor, _token: enqueued.append(action),
get_app_revision_fn=lambda _app_id, _actor: revision["value"],
)
session = _seed_session_at(repo, PcState.FIX_AWAIT_TESTDATA)
stored, context = repo.get_session(session.id)
context.last_snapshot_hash = "hash-1"
repo.compare_and_advance(session.id, stored.version, stored.current_state, context, [])
# The canvas autosaves while the user is still filling the form.
revision["value"] = "hash-2"
assert svc.get_session_view(session.id, _actor()).app_revision.conflicted is True
return svc, session
def test_providing_testdata_survives_a_stale_base_app_revision(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock
) -> None:
"""The gate asks a human to type for tens of seconds, right after Builder
wrote the canvas -- long enough for the editor's autosave to move the
revision. The submit carries run inputs, not graph edits, so refusing it
only stranded the user behind a recovery prompt with a frozen form."""
enqueued: list[Action] = []
svc, session = _drifted_testdata_session(repo, lock, enqueued)
svc.submit_action(
session.id,
_actor(),
Action(kind="provide_testdata", payload={"mode": "provide"}, base_version=2, base_app_revision="hash-1"),
)
assert [a.kind for a in enqueued] == ["provide_testdata"]
def test_providing_testdata_survives_a_missing_base_app_revision(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock
) -> None:
enqueued: list[Action] = []
svc, session = _drifted_testdata_session(repo, lock, enqueued)
svc.submit_action(
session.id,
_actor(),
Action(kind="provide_testdata", payload={"mode": "provide"}, base_version=2),
)
assert [a.kind for a in enqueued] == ["provide_testdata"]
def test_the_testdata_exemption_does_not_leak_to_other_actions(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock
) -> None:
# A drifted draft must still stop anything that would act on the graph.
current_revision = "hash-1"
svc = DifyBuilderService(
repo,
lock,
lambda *_args: None,
get_app_revision_fn=lambda _app_id, _actor: current_revision,
)
session = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
stored, context = repo.get_session(session.id)
context.last_snapshot_hash = "hash-1"
repo.compare_and_advance(session.id, stored.version, stored.current_state, context, [])
current_revision = "hash-2"
with pytest.raises(ConflictError, match="stale app revision"):
svc.submit_action(session.id, _actor(), Action(kind="run_verify", base_version=2, base_app_revision="hash-1"))
with pytest.raises(ConflictError, match="draft changed outside Builder"):
svc.submit_action(session.id, _actor(), Action(kind="run_verify", base_version=2, base_app_revision="hash-2"))
def test_submit_message_wraps_submit_action(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, lock: FakeSessionLock
) -> None:
actor = _actor()
session = _seed_session_at(repo, PcState.FIX_AWAIT_APPROVAL)
assert lock.acquire(session.id) is not None
# A waiting-state message is valid, but the held lock proves it routes
# through submit_action -> dispatch like a plain action.
with pytest.raises(BusyError):
service.submit_message(session.id, actor, "hello", base_version=1, client_turn_id="turn-1")
def test_paused_session_can_resume_before_classifying_external_draft_changes(
repo: SqlDifyBuilderRepository,
lock: FakeSessionLock,
) -> None:
current_revision = "hash-2"
service = DifyBuilderService(
repo,
lock,
lambda *_args: None,
get_app_revision_fn=lambda _app_id, _actor: current_revision,
)
session = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
stored, context = repo.get_session(session.id)
context.last_snapshot_hash = "hash-1"
context.paused = True
repo.compare_and_advance(session.id, stored.version, stored.current_state, context, [])
view = service.get_session_view(session.id, _actor())
assert view.run_status == "paused"
assert view.app_revision is not None
assert view.app_revision.conflicted is True
assert view.actions == []
_view, expect_advance = service._prepare_action(
session.id,
_actor(),
Action(kind="resume", base_version=2, base_app_revision=current_revision),
)
assert expect_advance is True
def _seed_free_session(repo: SqlDifyBuilderRepository) -> Session:
"""Create a session directly via the repo (bypassing ``create_fix_session``,
which would itself call ``dispatch`` -- and hold or fail on the lock)
so callers get a FREE-lock session with ``version == 1``."""
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.FIX,
current_state=PcState.FIX_AWAIT_VERIFY,
)
repo.create_session(s, DifyBuilderContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)])
return s
def _seed_session_at(repo: SqlDifyBuilderRepository, state: PcState) -> Session:
"""Create a session directly via the repo (bypassing ``create_fix_session``)
at an arbitrary ``PcState``, so ``get_session_view``'s actions-table lookup
can be tested without driving the whole flow through it."""
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.FIX,
current_state=state,
)
repo.create_session(s, DifyBuilderContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)])
return s
@pytest.mark.parametrize(
("state", "kind", "expected_access"),
[
(PcState.FIX_AWAIT_VERIFY, "run_verify", AppAccess.TEST_AND_RUN),
(PcState.BUILD_EXECUTION, "run_test", AppAccess.TEST_AND_RUN),
(PcState.EDIT_APPLY_CHANGES, "run_affected_tests", AppAccess.TEST_AND_RUN),
(PcState.BUILD_AWAIT_TESTDATA, "provide_testdata", AppAccess.TEST_AND_RUN),
(PcState.CHECKLIST_AWAIT_RECHECK, "recheck", AppAccess.TEST_AND_RUN),
(PcState.BUILD_AWAIT_REPAIR, "approve_repair", AppAccess.TEST_AND_RUN),
(PcState.FIX_AWAIT_VERIFY, "stop", AppAccess.TEST_AND_RUN),
(PcState.FIX_AWAIT_DECISION, "publish", AppAccess.RELEASE),
(PcState.BUILD_REVIEW, "publish_workflow", AppAccess.RELEASE),
(PcState.EDIT_REVIEW, "publish_workflow", AppAccess.RELEASE),
(PcState.BUILD_REVIEW, "keep_draft", AppAccess.EDIT),
],
)
def test_action_authorization_uses_state_specific_permission_tier(
repo: SqlDifyBuilderRepository,
lock: FakeSessionLock,
state: PcState,
kind: str,
expected_access: AppAccess,
) -> None:
session = _seed_session_at(repo, state)
accesses: list[AppAccess] = []
svc = DifyBuilderService(
repo,
lock,
lambda *_args: None,
authorize_app_fn=lambda _actor, _app_id, access: accesses.append(access),
)
_view, expect_advance = svc._prepare_action(session.id, _actor(), Action(kind=kind, base_version=1))
assert expect_advance is True
assert accesses[0] == AppAccess.EDIT
assert accesses[-1] == expected_access
@pytest.mark.parametrize("kind", ["", "unknown"])
def test_submit_action_rejects_missing_unknown_or_wrong_state_action_without_enqueue(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
kind: str,
) -> None:
session = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION)
with pytest.raises(BadRequestError):
service.submit_action(session.id, _actor(), Action(kind=kind, base_version=1))
persisted, _context = repo.get_session(session.id)
assert persisted.current_state == PcState.FIX_AWAIT_DECISION
assert persisted.version == 1
assert enqueued == []
def test_submit_action_rejects_removed_action_during_verification(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, enqueued: list[tuple]
) -> None:
session = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
with pytest.raises(BadRequestError, match="not allowed"):
service.submit_action(session.id, _actor(), Action(kind="view_changes", base_version=1))
assert enqueued == []
@pytest.mark.parametrize(
"action",
[
Action(kind="publish", payload=[], base_version=1),
Action(kind="publish", base_version=True),
Action(kind="publish", base_version="1"),
Action(kind="message", payload={"text": 1}, base_version=1),
Action(kind="message", payload={"text": " "}, base_version=1),
],
)
def test_submit_action_rejects_malformed_action_without_enqueue(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
action: Action,
) -> None:
session = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION)
with pytest.raises(BadRequestError):
service.submit_action(session.id, _actor(), action)
assert enqueued == []
def test_internal_action_state_guards(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
) -> None:
waiting = _seed_session_at(repo, PcState.FIX_AWAIT_APPROVAL)
_view, expect_advance = service._prepare_action(
waiting.id,
_actor(),
Action(kind="message", payload={"text": "context", "client_turn_id": "turn-1"}, base_version=1),
)
assert expect_advance is True
working = _seed_session_at(repo, PcState.FIX_DIAGNOSE)
for kind, payload in (
("message", {"text": "context", "client_turn_id": "turn-2"}),
("update_model", {}),
("check_recovery", {}),
):
with pytest.raises(BadRequestError, match="not allowed"):
service.submit_action(working.id, _actor(), Action(kind=kind, payload=payload, base_version=1))
terminal = _seed_session_at(repo, PcState.SUCCESS)
with pytest.raises(BadRequestError, match="not allowed"):
service.submit_message(terminal.id, _actor(), "context", base_version=1, client_turn_id="turn-3")
assert enqueued == []
def test_stop_resume_and_recovery_actions_require_current_context(
repo: SqlDifyBuilderRepository, lock: FakeSessionLock
) -> None:
session = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_REVIEW,
)
repo.create_session(session, DifyBuilderContext(), [])
svc = DifyBuilderService(repo, lock, lambda *_args: None)
svc._prepare_action(session.id, _actor(), Action(kind="stop", base_version=1))
for kind in ("resume", "recovery_continue", "recovery_restart"):
with pytest.raises(BadRequestError, match="not allowed"):
svc._prepare_action(session.id, _actor(), Action(kind=kind, base_version=1))
_session, context = repo.get_session(session.id)
context.paused = True
context.recovery_class = "config_only"
repo.compare_and_advance(session.id, 1, PcState.BUILD_REVIEW, context, [])
svc._prepare_action(session.id, _actor(), Action(kind="resume", base_version=2))
for kind in ("recovery_continue", "recovery_restart", "stop"):
with pytest.raises(BadRequestError, match="not allowed"):
svc._prepare_action(session.id, _actor(), Action(kind=kind, base_version=2))
context.paused = False
repo.compare_and_advance(session.id, 2, PcState.BUILD_REVIEW, context, [])
for kind, payload in (
("message", {"text": "context", "client_turn_id": "turn-1"}),
("update_model", {}),
("check_recovery", {}),
("stop", {}),
):
with pytest.raises(BadRequestError, match="not allowed"):
svc._prepare_action(session.id, _actor(), Action(kind=kind, payload=payload, base_version=3))
svc._prepare_action(session.id, _actor(), Action(kind="recovery_continue", base_version=3))
svc._prepare_action(session.id, _actor(), Action(kind="recovery_restart", base_version=3))
def test_get_session_view_actions_for_fix_await_decision(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION)
actor = _actor()
view = service.get_session_view(s.id, actor)
assert [(a.id, a.kind) for a in view.actions] == [
("publish_fix", ActionKind.PRIMARY),
("keep_draft", ActionKind.SECONDARY),
("continue_adjusting", ActionKind.SECONDARY),
("revert", ActionKind.DESTRUCTIVE),
]
def test_session_view_carries_the_same_choice_as_options_and_as_actions(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION)
view = service.get_session_view(s.id, _actor())
# Both contracts are live while the web client migrates, and they must never
# disagree about what this gate is asking.
assert [o.id for o in view.decision.options] == [a.id for a in view.actions]
assert view.decision.default_option_id == "publish_fix"
assert (view.decision.confirm.id, view.decision.cancel.id) == ("confirm", "cancel")
def test_a_session_view_with_nothing_to_decide_has_no_decision(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.BUILD_COMPLETE)
view = service.get_session_view(s.id, _actor())
# A finished session asks nothing, so there is no card to put options in.
assert view.actions == []
assert view.decision is None
def test_get_session_view_actions_for_fix_await_verify(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
actor = _actor()
view = service.get_session_view(s.id, actor)
assert [(a.id, a.kind) for a in view.actions] == [
("run_validation", ActionKind.PRIMARY),
("revert", ActionKind.DESTRUCTIVE),
]
def test_get_session_view_offers_restart_for_interrupted_working_state(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.FIX_DIAGNOSE)
actor = _actor()
view = service.get_session_view(s.id, actor)
assert view.interrupted is True
# Interrupted working step offers Retry (re-run the handler) + Start over.
assert [action.id for action in view.actions] == ["recovery_continue", "restart"]
def test_get_session_view_actions_empty_for_terminal_state(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.SUCCESS)
actor = _actor()
view = service.get_session_view(s.id, actor)
assert view.actions == []
def test_resolve_action_kind_maps_new_ids_to_handler_kinds() -> None:
assert resolve_action_kind("run_validation") == "run_verify"
assert resolve_action_kind("publish_fix") == "publish"
assert resolve_action_kind("approve_plan") == "approve_repair"
assert resolve_action_kind("continue_adjusting") == "re_fix"
assert resolve_action_kind("pause") == "stop"
assert resolve_action_kind("revert") == "undo"
assert resolve_action_kind("restart") == "recovery_restart"
assert resolve_action_kind("retry_after_revert") == "re_fix"
def test_resolve_action_kind_passes_through_ids_that_match_handler_kinds() -> None:
assert resolve_action_kind("run_verify") == "run_verify"
assert resolve_action_kind("recheck") == "recheck"
assert resolve_action_kind("provide_testdata") == "provide_testdata"
assert resolve_action_kind("keep_draft") == "keep_draft"
def test_waiting_state_actions_resolve_to_handled_kinds() -> None:
"""For EACH Fix/Checklist waiting state in ``_ACTIONS_FOR``, every
surfaced action id must resolve (via
``resolve_action_kind``) to a handler kind that state's handler in
``handlers_fix.py`` actually branches on -- otherwise the button is dead
(falls through to the state's default no-op/branch) rather than doing
what its label promises.
Regression for the FIX_AWAIT_APPROVAL ``revert`` dead button:
``resolve_action_kind("revert") == "undo"``, but
``handle_await_approval`` only branches on ``approve_repair`` /
``reject_repair`` -- ``undo`` fell through to the gate's no-op branch,
so the reject path was unreachable from the FE."""
handled_kinds: dict[PcState, set[str]] = {
PcState.FIX_AWAIT_APPROVAL: {"approve_repair", "reject_repair"},
PcState.FIX_AWAIT_VERIFY: {"run_verify", "undo"},
PcState.FIX_AWAIT_TESTDATA: {"provide_testdata"},
PcState.FIX_AWAIT_DECISION: {"publish", "keep_draft", "re_fix", "undo"},
PcState.CHECKLIST_AWAIT_RECHECK: {"recheck", "undo"},
}
for state, handled in handled_kinds.items():
actions = service_module._ACTIONS_FOR[state]
resolved = {resolve_action_kind(a.id) for a in actions}
assert resolved <= handled, f"{state}: resolved kinds {resolved} not handled by its handler ({handled})"
def test_every_backend_waiting_action_has_a_ui_path() -> None:
# No exemptions needed: BUILD_INITIAL_PLAN is a working/pass-through
# state now (state.py), not a waiting one, so it has no entry in
# _BACKEND_ACTIONS_FOR at all -- see test_initial_plan_state_offers_no_actions.
for state, backend_kinds in service_module._BACKEND_ACTIONS_FOR.items():
surfaced_kinds = {resolve_action_kind(action.id) for action in service_module._ACTIONS_FOR.get(state, [])}
assert backend_kinds <= surfaced_kinds, f"{state}: hidden backend actions {backend_kinds - surfaced_kinds}"
@pytest.mark.parametrize("state", [PcState.FIX_AWAIT_DECISION, PcState.BUILD_REVIEW, PcState.EDIT_REVIEW])
@pytest.mark.parametrize("streaming", [False, True])
def test_submit_action_rejects_view_changes_without_advancing(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
state: PcState,
streaming: bool,
) -> None:
"""Removed actions must not reach handlers that default to keeping the draft."""
session = _seed_session_at(repo, state)
submit = service.submit_action_stream if streaming else service.submit_action
with pytest.raises(BadRequestError, match="not allowed"):
submit(session.id, _actor(), Action(kind="view_changes", base_version=session.version))
persisted, _context = repo.get_session(session.id)
assert persisted.current_state == state
assert persisted.version == session.version
assert enqueued == []
def test_submit_action_update_model_persists_without_dispatch(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
model_config = {
"provider": "openai",
"name": "gpt-4o",
"mode": "chat",
"completion_params": {"temperature": 0.2},
}
validated: list[tuple[str, dict]] = []
def validate_model(tenant_id: str, config: dict) -> dict:
validated.append((tenant_id, config))
return config
monkeypatch.setattr(
service_module,
"validate_model_config",
validate_model,
)
view = service.submit_action(
s.id,
_actor(),
Action(kind="update_model", payload={"model_config": model_config}, base_version=s.version),
)
assert validated == [(TENANT_ID, model_config)]
assert view.version == s.version + 1
assert view.model is not None
assert view.model.provider == "openai"
assert view.model.name == "gpt-4o"
assert view.model.mode == "chat"
assert view.model.completion_params == {"temperature": 0.2}
assert repo.list_conversation(s.id)[-1].payload["text"] == "Model changed to gpt-4o"
assert enqueued == []
def test_submit_action_update_model_localizes_notice(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""update_model commits its notice directly (bypassing the engine _commit
localization hook), so the service must localize it itself when the session
has a detected reply_language."""
import services.dify_builder.agent.localize as localize_mod
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.FIX,
current_state=PcState.FIX_AWAIT_VERIFY,
)
repo.create_session(
s,
DifyBuilderContext(failed_run_id="TR-1", reply_language="ja"),
[ConversationItem(kind="run-context", seq=0)],
)
monkeypatch.setattr(service_module, "validate_model_config", lambda _tenant_id, config: config)
class _FakeLocalizer:
def __init__(self, _provider):
pass
def localize_items(self, items, language):
for it in items:
if "text" in it.payload:
it.payload["text"] = f"[{language}] {it.payload['text']}"
return items
monkeypatch.setattr(localize_mod, "Localizer", _FakeLocalizer)
model_config = {"provider": "openai", "name": "gpt-4o", "mode": "chat", "completion_params": {}}
service.submit_action(
s.id,
_actor(),
Action(kind="update_model", payload={"model_config": model_config}, base_version=s.version),
)
assert repo.list_conversation(s.id)[-1].payload["text"] == "[ja] Model changed to gpt-4o"
assert enqueued == []
def test_submit_action_update_model_rejects_missing_config(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
with pytest.raises(BadRequestError, match="model_config is required"):
service.submit_action(
s.id,
_actor(),
Action(kind="update_model", payload={}, base_version=s.version),
)
def test_submit_action_stream_update_model_emits_terminal_state_frame(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``update_model`` settles synchronously and bumps the version. The stream
MUST emit a terminal ``state`` frame carrying the new version -- otherwise a
FE that tracks its held version off ``state`` frames keeps the stale value
and its next action 409s (regression class from the 409 review)."""
s = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
model_config = {"provider": "openai", "name": "gpt-4o", "mode": "chat", "completion_params": {}}
monkeypatch.setattr(service_module, "validate_model_config", lambda _tenant_id, config: config)
action = Action(kind="update_model", payload={"model_config": model_config}, base_version=s.version)
frames = list(service.submit_action_stream(s.id, _actor(), action))
assert _business_event(frames[0]) == "command_started"
state_frames = [f for f in frames if _business_event(f) == "state"]
assert state_frames, "update_model must emit a terminal state frame with the new version"
def test_submit_action_stream_subscribes_before_dispatch_and_streams(repo: SqlDifyBuilderRepository) -> None:
lock = FakeSessionLock()
order: list[str] = []
class _Sub:
def receive(self, timeout): # noqa: ARG002
return json.dumps({"kind": "state", "version": 2, "session_id": "sid"}).encode()
def close(self):
pass
def fake_subscribe(_sid):
order.append("subscribe")
return _Sub()
def fake_enqueue(*_a, **_k):
order.append("enqueue")
s = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
svc = DifyBuilderService(repo, lock, fake_enqueue, subscribe_fn=fake_subscribe)
actor = _actor()
action = Action(kind="run_verify", base_version=s.version)
gen = svc.submit_action_stream(s.id, actor, action)
frames = list(gen)
assert order == ["subscribe", "enqueue"] # subscribe strictly before dispatch
assert _business_event(frames[0]) == "command_started"
assert any(_business_event(frame) == "state" for frame in frames)
def test_submit_action_stream_raises_conflict_before_streaming(repo: SqlDifyBuilderRepository) -> None:
lock = FakeSessionLock()
subscribed: list[str] = []
s = _seed_session_at(repo, PcState.FIX_AWAIT_VERIFY)
svc = DifyBuilderService(repo, lock, lambda *_a, **_k: None, subscribe_fn=lambda sid: subscribed.append(sid))
actor = _actor()
action = Action(kind="run_verify", base_version=s.version + 99) # stale
with pytest.raises(ConflictError):
svc.submit_action_stream(s.id, actor, action)
assert subscribed == [] # no subscription opened on a pre-flight error
def test_create_fix_session_stream_subscribes_before_dispatch(inmemory_service_factory_raw) -> None:
order: list[str] = []
svc, actor = inmemory_service_factory_raw(
subscribe_fn=lambda _sid: (order.append("subscribe"), _StateSub())[1],
enqueue_fn=lambda *_a, **_k: order.append("enqueue"),
)
gen = svc.create_fix_session_stream(
app_id=APP_ID, actor=actor, failed_run_id="run-1", checklist_errors=None, model_config=None
)
frames = list(gen)
assert order == ["subscribe", "enqueue"]
assert _business_event(frames[0]) == "command_started"
def test_create_build_session_stream_subscribes_before_dispatch(inmemory_service_factory_raw) -> None:
order: list[str] = []
svc, actor = inmemory_service_factory_raw(
subscribe_fn=lambda _sid: (order.append("subscribe"), _StateSub())[1],
enqueue_fn=lambda *_a, **_k: order.append("enqueue"),
)
gen = svc.create_build_session_stream(app_id=APP_ID, actor=actor, goal_text="Build a report workflow")
frames = list(gen)
assert order == ["subscribe", "enqueue"]
assert _business_event(frames[0]) == "command_started"
command_started = json.loads(frames[0].split("data: ", 1)[1])["data"]
assert command_started["actions"] == []
assert command_started["decision"] is None
def test_invalid_create_streams_raise_before_subscribe(inmemory_service_factory_raw) -> None:
subscribed: list[str] = []
svc, actor = inmemory_service_factory_raw(
subscribe_fn=lambda sid: subscribed.append(sid),
enqueue_fn=lambda *_a, **_k: None,
)
with pytest.raises(BadRequestError, match="goal_text is required"):
svc.create_build_session_stream(app_id=APP_ID, actor=actor, goal_text=" ")
with pytest.raises(BadRequestError, match="goal_text is required"):
svc.create_edit_session_stream(app_id=APP_ID, actor=actor, goal_text=" ")
with pytest.raises(BadRequestError, match="failed_run_id or checklist_errors is required"):
svc.create_fix_session_stream(app_id=APP_ID, actor=actor)
assert subscribed == []
def test_create_edit_session_stream_subscribes_before_initial_goal_dispatch(inmemory_service_factory_raw) -> None:
order: list[str] = []
captured_actions: list[Action] = []
def enqueue(_session_id, action, _actor, _token) -> None:
order.append("enqueue")
captured_actions.append(action)
svc, actor = inmemory_service_factory_raw(
subscribe_fn=lambda _sid: (order.append("subscribe"), _StateSub())[1],
enqueue_fn=enqueue,
)
frames = list(
svc.create_edit_session_stream(
app_id=APP_ID,
actor=actor,
goal_text="Tighten risk handling",
)
)
assert order == ["subscribe", "enqueue"]
assert captured_actions == [
Action(kind="send_edit_goal", payload={"text": "Tighten risk handling"}, base_version=1)
]
assert _business_event(frames[0]) == "command_started"
assert any(_business_event(frame) == "state" for frame in frames)
@pytest.mark.parametrize(
("method_name", "kwargs"),
[
("create_fix_session_stream", {"failed_run_id": "run-1"}),
("create_build_session_stream", {"goal_text": "Build it"}),
("create_edit_session_stream", {"goal_text": "Edit it"}),
],
)
def test_create_stream_closes_subscription_when_state_serialization_fails(
inmemory_service_factory_raw,
monkeypatch: pytest.MonkeyPatch,
method_name: str,
kwargs: dict,
) -> None:
class _TrackingSub:
closed = False
def close(self):
self.closed = True
subscription = _TrackingSub()
svc, actor = inmemory_service_factory_raw(subscribe_fn=lambda _sid: subscription)
monkeypatch.setattr(service_module, "asdict", lambda _view: (_ for _ in ()).throw(RuntimeError("projection")))
with pytest.raises(RuntimeError, match="projection"):
getattr(svc, method_name)(app_id=APP_ID, actor=actor, **kwargs)
assert subscription.closed is True
def test_create_stream_closes_subscription_when_state_projection_fails(
inmemory_service_factory_raw, monkeypatch: pytest.MonkeyPatch
) -> None:
class _TrackingSub:
closed = False
def close(self):
self.closed = True
subscription = _TrackingSub()
svc, actor = inmemory_service_factory_raw(subscribe_fn=lambda _sid: subscription)
monkeypatch.setattr(svc, "get_session_view", lambda *_args: (_ for _ in ()).throw(RuntimeError("view")))
with pytest.raises(RuntimeError, match="view"):
svc.create_build_session_stream(app_id=APP_ID, actor=actor, goal_text="Build it")
assert subscription.closed is True
def test_dispatch_releases_lock_when_enqueue_fails(repo: SqlDifyBuilderRepository) -> None:
lock = FakeSessionLock()
def raising_enqueue(*_args) -> None:
raise RuntimeError("boom")
s = _seed_free_session(repo)
svc = DifyBuilderService(repo, lock, raising_enqueue)
actor = _actor()
# dispatch must propagate the enqueue's original exception, not swallow
# it or turn it into BusyError.
with pytest.raises(RuntimeError):
svc.submit_action(s.id, actor, Action(kind="run_verify", base_version=1))
# ...and it must release the lock it acquired, so the session isn't
# wedged by a failed enqueue.
assert lock.exists(s.id) is False
assert lock.acquire(s.id) is not None
def test_submit_message_constructs_message_action(repo: SqlDifyBuilderRepository) -> None:
lock = FakeSessionLock()
captured: list[tuple] = []
def capturing_enqueue(session_id, action, actor, token) -> None:
captured.append((session_id, action, actor, token))
# seeded directly so the lock is FREE and dispatch actually reaches
# enqueue_fn (after create_fix_session the lock would already be held,
# and dispatch would raise BusyError before capturing anything).
s = _seed_free_session(repo)
svc = DifyBuilderService(repo, lock, capturing_enqueue)
actor = _actor()
svc.submit_message(s.id, actor, "hello", base_version=1, client_turn_id="turn-1")
assert len(captured) == 1
_sid, action, _dispatched_actor, _token = captured[0]
assert action.kind == "message"
assert action.payload == {"text": "hello", "client_turn_id": "turn-1"}
assert action.base_version == 1
def test_settled_message_retry_uses_targeted_turn_lookup(
repo: SqlDifyBuilderRepository, monkeypatch: pytest.MonkeyPatch
) -> None:
session = _seed_free_session(repo)
stored, context = repo.get_session(session.id)
context.next_seq = 3
repo.compare_and_advance(
session.id,
stored.version,
stored.current_state,
context,
[
ConversationItem(seq=1, kind="user", payload={"text": "hello", "turn_id": "turn-1"}),
ConversationItem(seq=2, kind="assistant_turn", payload={"reply_text": "hi", "turn_id": "turn-1"}),
],
)
def reject_full_history(_session_id: str):
raise AssertionError("message preparation must not load the full conversation")
monkeypatch.setattr(repo, "list_conversation", reject_full_history)
service = DifyBuilderService(repo, FakeSessionLock(), lambda *_args: None)
view, expect_advance = service._prepare_action(
session.id,
_actor(),
Action(
kind="message",
payload={"text": "hello", "client_turn_id": "turn-1"},
base_version=1,
),
)
assert expect_advance is False
assert view.version == 2
def test_submit_message_rejects_blank_text(service: DifyBuilderService) -> None:
with pytest.raises(BadRequestError, match="message text is required"):
service.submit_message("session-1", _actor(), " ", base_version=1, client_turn_id="turn-1")
def test_actions_for_await_learning() -> None:
ids = [a.id for a in service_module._actions_for(PcState.BUILD_AWAIT_LEARNING)]
assert ids == ["accept_learning", "skip_learning"]
def test_initial_plan_state_offers_no_actions() -> None:
# The straight-through flow no longer stops at build.initial_plan
# (submit_requirements now goes straight to resource discovery), and it is
# a working/pass-through state now (state.py), not a waiting one -- so it
# offers no UI action. It survives only as the continue_adjusting/
# retry_after_revert loop-back target, where the runner drives its
# handler unconditionally on entry with no action to wait for.
assert service_module._actions_for(PcState.BUILD_INITIAL_PLAN) == []
def test_capability_check_hides_redundant_send_goal_after_goal_is_seeded() -> None:
seeded = DifyBuilderContext(goal_text="Build a report workflow")
assert service_module._actions_for(PcState.BUILD_CAPABILITY_CHECK, seeded) == []
# Preserve the explicit create-from-blank fallback: only a genuinely
# goal-less context still needs a send_goal interaction.
goal_less = DifyBuilderContext()
assert [action.id for action in service_module._actions_for(PcState.BUILD_CAPABILITY_CHECK, goal_less)] == [
"send_goal"
]
def test_create_build_session_stamps_policy(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``create_build_session`` reads
``FeatureService.get_features(actor.tenant_id).skill_learning_policy`` and
stamps it onto ``fc`` at creation. Monkeypatched to a non-default value
("automatic", not the ``DifyBuilderContext`` field default "ask") so this
only passes if the value genuinely round-trips through FeatureService --
not merely because it matches the field's own default."""
from services import feature_service as feature_service_module
monkeypatch.setattr(feature_service_module.dify_config, "DIFY_BUILDER_SKILL_LEARNING_POLICY", "automatic")
view = service.create_build_session(APP_ID, _actor(), goal_text="Build a report workflow")
_session, fc = repo.get_session(view.session_id)
assert fc.skill_learning_policy == "automatic"
def test_create_build_session_bootstraps_at_capability_check_and_dispatches_send_goal(
service: DifyBuilderService, enqueued: list[tuple]
) -> None:
view = service.create_build_session(APP_ID, _actor(), goal_text=" Build a report workflow ")
assert view.state == "build.capability_check"
assert view.entry_mode == EntryMode.BUILD
assert view.version == 1
assert view.actions == []
assert view.decision is None
assert len(enqueued) == 1
_sid, action, _actor2, _token = enqueued[0]
assert action.kind == "send_goal"
assert action.payload == {"text": "Build a report workflow"}
assert action.base_version == 1
def _seed_build_at(repo: SqlDifyBuilderRepository, state: PcState) -> Session:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=state,
)
repo.create_session(s, DifyBuilderContext(goal_text="Build it"), [ConversationItem(kind="user", seq=0)])
return s
def test_build_execution_actions_and_run_status(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = _seed_build_at(repo, PcState.BUILD_EXECUTION)
view = service.get_session_view(s.id, _actor())
assert view.run_status == "waiting_confirmation"
assert view.phase == "modify"
assert [(a.id, a.kind) for a in view.actions] == [
("run_test", ActionKind.PRIMARY),
("revert", ActionKind.DESTRUCTIVE),
]
def test_session_view_exposes_only_the_current_interactive_card(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_GOAL_ANALYSIS,
)
items = [
ConversationItem(
seq=0,
kind="form",
payload={"variant": "build_requirements", "fields": [], "values": {"audience": "ops"}},
),
ConversationItem(
seq=1,
kind="assistant_turn",
payload={
"cards": ["form"],
"turn_id": "turn-1",
"execution": {"status": "completed"},
},
),
ConversationItem(seq=2, kind="notice", payload={"text": "A later chat turn"}),
]
repo.create_session(s, DifyBuilderContext(goal_text="Build it"), items)
view = service.get_session_view(s.id, _actor())
assert view.conversation_last_seq == 2
assert view.active_interaction is not None
assert view.active_interaction.action_id == "submit_requirements"
assert view.active_interaction.card.seq == 0
assert view.active_interaction.valid_at_version == view.version
def test_conversation_page_requires_session_owner(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = _seed_build_at(repo, PcState.BUILD_GOAL_ANALYSIS)
with pytest.raises(NotFoundError):
service.get_conversation_page(s.id, _actor(OTHER_ACCOUNT_ID), limit=20)
def test_build_review_actions(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = _seed_build_at(repo, PcState.BUILD_REVIEW)
view = service.get_session_view(s.id, _actor())
assert [a.id for a in view.actions] == [
"publish_workflow",
"keep_draft",
"continue_adjusting",
"revert",
]
def test_build_complete_is_terminal_with_no_actions(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_build_at(repo, PcState.BUILD_COMPLETE)
view = service.get_session_view(s.id, _actor())
assert view.run_status == "complete"
assert view.actions == []
def test_build_waiting_state_actions_resolve_to_handled_kinds() -> None:
"""Every Build action id must resolve (via
resolve_action_kind) to a kind its handler in handlers_build.py branches
on -- otherwise the button is a dead no-op. approve_plan->approve_repair,
revert->undo, continue_adjusting/retry_after_revert->re_fix reuse the
existing global _ACTION_ID_TO_KIND map; the rest pass through."""
# BUILD_INITIAL_PLAN is deliberately absent: it is a working/pass-through
# state now (state.py), not a waiting one, so it has no _ACTIONS_FOR entry
# to check here (see test_initial_plan_state_offers_no_actions) -- its
# unconditional discovery-on-entry behavior is exercised directly against
# the handler and end to end in test_handlers_build.py instead.
handled: dict[PcState, set[str]] = {
PcState.BUILD_CAPABILITY_CHECK: {"send_goal"},
PcState.BUILD_GOAL_ANALYSIS: {"submit_requirements"},
PcState.BUILD_RESOURCE_RECOMMENDATION: {"confirm_resources"},
PcState.BUILD_PLAN_APPROVAL: {"approve_repair"},
PcState.BUILD_EXECUTION: {"run_test", "undo"},
PcState.BUILD_REVIEW: {"publish_workflow", "keep_draft", "re_fix", "undo"},
PcState.BUILD_REVERTED: {"re_fix"},
}
for state, kinds in handled.items():
actions = service_module._ACTIONS_FOR[state]
resolved = {resolve_action_kind(a.id) for a in actions}
assert resolved <= kinds, f"{state}: resolved {resolved} not handled by its handler ({kinds})"
def test_sql_repo_invalidate_conversation_items(repo: SqlDifyBuilderRepository) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.EDIT,
current_state=PcState.EDIT_REVIEW,
)
items = [
ConversationItem(seq=0, kind="assistant_turn", payload={"turn_id": "t0"}),
ConversationItem(seq=1, kind="assistant_turn", payload={"turn_id": "t1"}),
ConversationItem(seq=2, kind="decision", payload={"text": "x"}),
]
repo.create_session(s, DifyBuilderContext(), items)
repo.invalidate_conversation_items(s.id, from_seq=1)
by_seq = {i.seq: i for i in repo.list_conversation(s.id)}
assert "card_state" not in by_seq[0].payload
assert by_seq[1].payload["card_state"] == "invalidated"
assert by_seq[2].payload.get("card_state") is None
def _seed_edit_at(repo: SqlDifyBuilderRepository, state: PcState) -> Session:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.EDIT,
current_state=state,
)
repo.create_session(s, DifyBuilderContext(goal_text="Tighten risk"), [ConversationItem(kind="user", seq=0)])
return s
def test_create_edit_session_rejects_blank_goal_without_dispatch(
service: DifyBuilderService, enqueued: list[tuple]
) -> None:
with pytest.raises(BadRequestError, match="goal_text is required"):
service.create_edit_session(APP_ID, _actor(), goal_text=" ")
assert enqueued == []
def test_create_edit_session_seeds_and_dispatches_opening_goal(
service: DifyBuilderService,
repo: SqlDifyBuilderRepository,
enqueued: list[tuple],
) -> None:
view = service.create_edit_session(APP_ID, _actor(), goal_text=" Tighten risk handling ")
assert view.state == "edit.capability_check"
assert view.entry_mode == EntryMode.EDIT
conversation = repo.list_conversation(view.session_id)
assert [(item.kind, item.payload["text"]) for item in conversation] == [("user", "Tighten risk handling")]
assert isinstance(conversation[0].payload["turn_id"], str)
_session, context = repo.get_session(view.session_id)
assert context.goal_text == "Tighten risk handling"
assert len(enqueued) == 1
session_id, action, actor, _token = enqueued[0]
assert session_id == view.session_id
assert actor == _actor()
assert action == Action(kind="send_edit_goal", payload={"text": "Tighten risk handling"}, base_version=1)
def test_edit_apply_changes_actions_and_run_status(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = _seed_edit_at(repo, PcState.EDIT_APPLY_CHANGES)
view = service.get_session_view(s.id, _actor())
assert view.run_status == "waiting_confirmation"
assert view.phase == "modify"
assert [(a.id, a.kind) for a in view.actions] == [
("run_affected_tests", ActionKind.PRIMARY),
("revert", ActionKind.DESTRUCTIVE),
]
def test_edit_review_actions(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = _seed_edit_at(repo, PcState.EDIT_REVIEW)
view = service.get_session_view(s.id, _actor())
assert [a.id for a in view.actions] == [
"publish_workflow",
"keep_draft",
"continue_adjusting",
"revert",
]
def test_edit_complete_is_terminal_with_no_actions(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = _seed_edit_at(repo, PcState.EDIT_COMPLETE)
view = service.get_session_view(s.id, _actor())
assert view.run_status == "complete"
assert view.actions == []
def test_edit_waiting_state_actions_resolve_to_handled_kinds() -> None:
"""Every Edit action id must resolve (via
resolve_action_kind) to a kind its handler in handlers_edit.py branches on.
approve_plan->approve_repair, revert->undo, continue_adjusting/
retry_after_revert->re_fix reuse the existing global map; the rest pass
through (send_edit_goal, submit_edit_rules, run_affected_tests, keep_draft,
publish_workflow)."""
handled: dict[PcState, set[str]] = {
PcState.EDIT_CAPABILITY_CHECK: {"send_edit_goal"},
PcState.EDIT_IMPACT_ANALYSIS: {"submit_edit_rules"},
PcState.EDIT_PLAN_APPROVAL: {"approve_repair"},
PcState.EDIT_APPLY_CHANGES: {"run_affected_tests", "undo"},
PcState.EDIT_REVIEW: {"publish_workflow", "keep_draft", "re_fix", "undo"},
PcState.EDIT_REVERTED: {"re_fix"},
}
for state, kinds in handled.items():
actions = service_module._ACTIONS_FOR[state]
resolved = {resolve_action_kind(a.id) for a in actions}
assert resolved <= kinds, f"{state}: resolved {resolved} not handled by its handler ({kinds})"
def test_get_session_view_surfaces_checkpoint_when_set(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_EXECUTION,
)
repo.create_session(s, DifyBuilderContext(checkpoint_id="cp-123"), [ConversationItem(kind="user", seq=0)])
view = service.get_session_view(s.id, _actor())
assert view.checkpoint is not None
assert view.checkpoint.checkpoint_id == "cp-123"
def test_get_session_view_no_checkpoint_when_unset(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_INITIAL_PLAN,
)
repo.create_session(s, DifyBuilderContext(), [ConversationItem(kind="user", seq=0)])
view = service.get_session_view(s.id, _actor())
assert view.checkpoint is None
def test_get_session_view_run_status_paused(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.EDIT,
current_state=PcState.EDIT_REVIEW,
)
repo.create_session(s, DifyBuilderContext(paused=True), [ConversationItem(kind="user", seq=0)])
view = service.get_session_view(s.id, _actor())
assert view.run_status == "paused"
assert view.canvas_read_only is False # editable while paused
assert view.actions == []
def test_recovery_ref_for():
# A session whose fc has a recovery_class surfaces a RecoveryRef;
# can_continue/can_restart follow the class.
from core.dify_builder import recovery
from core.dify_builder.contract import RecoveryClass
ref = recovery.recovery_ref_for(str(RecoveryClass.STRUCTURAL_INVALIDATING))
assert ref is not None
assert ref.recovery_class == "structural_invalidating"
assert ref.can_continue is False # cannot continue when invalidating
assert ref.can_restart is True
ref2 = recovery.recovery_ref_for(str(RecoveryClass.UNCHANGED))
assert ref2 is not None
assert ref2.can_continue is True
assert ref2.can_restart is False # nothing to restart from when unchanged
assert recovery.recovery_ref_for("") is None
# the two "keep going either way" classes: both continuing and restarting
# are offered, and .message comes from the dict-lookup path (recovery.
# _RECOVERY_MESSAGE), not the RecoveryRef.get(..., "") fallback.
for cls in (RecoveryClass.CONFIG_ONLY, RecoveryClass.STRUCTURAL_COMPATIBLE):
ref3 = recovery.recovery_ref_for(str(cls))
assert ref3 is not None
assert ref3.can_continue is True
assert ref3.can_restart is True
assert ref3.message
assert ref3.message == recovery._RECOVERY_MESSAGE[cls]
def test_get_session_view_surfaces_recovery_when_set(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_EXECUTION,
)
repo.create_session(s, DifyBuilderContext(recovery_class="config_only"), [ConversationItem(kind="user", seq=0)])
view = service.get_session_view(s.id, _actor())
assert view.recovery is not None
assert view.recovery.recovery_class == "config_only"
def test_get_session_view_no_recovery_when_unset(service: DifyBuilderService, repo: SqlDifyBuilderRepository) -> None:
s = Session(
app_id=APP_ID,
tenant_id=TENANT_ID,
owner_account_id=ACCOUNT_ID,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_INITIAL_PLAN,
)
repo.create_session(s, DifyBuilderContext(), [ConversationItem(kind="user", seq=0)])
view = service.get_session_view(s.id, _actor())
assert view.recovery is None
def test_interrupted_working_state_surfaces_recovery_offer(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.BUILD_PUBLISH) # working state, lock unheld -> interrupted
view = service.get_session_view(s.id, _actor())
assert view.interrupted is True
# The interrupted offer is projected as concrete actions: Retry + Start over.
assert [action.id for action in view.actions] == ["recovery_continue", "restart"]
# `recovery` carries only a waiting-gate drift class; a fresh interrupt has none.
assert view.recovery is None
def test_recovery_actions_allowed_at_interrupted_working_state() -> None:
from core.dify_builder.models import DifyBuilderContext
from services.dify_builder.service import _internal_action_allowed
fc = DifyBuilderContext()
assert _internal_action_allowed(PcState.BUILD_PUBLISH, fc, "recovery_continue") is True
assert _internal_action_allowed(PcState.BUILD_PUBLISH, fc, "recovery_restart") is True
# an unrelated action is still rejected at a working state
assert _internal_action_allowed(PcState.BUILD_PUBLISH, fc, "publish_workflow") is False
def test_retry_whose_handler_raises_lands_in_restartable_failed_state(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, lock: FakeSessionLock, monkeypatch
) -> None:
"""A Retry whose handler raises again must not wedge the session.
The task persists the caught failure, releases the lock, and publishes the
authoritative failed-state projection with its restart action.
"""
import tasks.dify_builder_advance_task as advance_mod
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort
class RaisingDify(FakeBuildDifyPort):
def publish(self, _app_id, _actor) -> None:
raise RuntimeError("boom: publish still broken")
monkeypatch.setattr(advance_mod, "_build_repo", lambda: repo)
monkeypatch.setattr(advance_mod, "WorkflowServiceDifyPort", RaisingDify)
monkeypatch.setattr(advance_mod, "session_lock", lock)
events: list[tuple[str, dict]] = []
monkeypatch.setattr(advance_mod.progress_bus, "publish", lambda sid, ev: events.append((sid, ev)))
s = _seed_session_at(repo, PcState.BUILD_PUBLISH) # already interrupted: working, lock unheld
actor = _actor()
token = lock.acquire(s.id)
assert token is not None
action = {"kind": "recovery_continue", "payload": {}, "base_version": s.version}
advance_mod.advance_session(s.id, action, {"account_id": actor.account_id, "tenant_id": actor.tenant_id}, token)
assert not lock.exists(s.id)
state_event = next(ev for _sid, ev in events if ev["kind"] == "state")
assert state_event["state"] == "failed"
assert state_event["run_status"] == "failed"
assert [item["id"] for item in state_event["actions"]] == ["restart"]
view = service.get_session_view(s.id, actor)
assert view.state == str(PcState.FAILED)
assert view.interrupted is False
assert view.recovery is None
assert [item.id for item in view.actions] == ["restart"]
def test_recovery_continue_retry_carries_the_working_states_access_tier() -> None:
"""A recovery_continue (Retry) re-runs the interrupted working handler, so it
must be gated at that op's tier: RELEASE at publish states, TEST_AND_RUN at test
states, EDIT elsewhere. recovery_restart (reset only) stays EDIT."""
from services.dify_builder.service import AppAccess, _app_access_for_action
assert _app_access_for_action(PcState.BUILD_PUBLISH, "recovery_continue") == AppAccess.RELEASE
assert _app_access_for_action(PcState.FIX_PUBLISH, "recovery_continue") == AppAccess.RELEASE
assert _app_access_for_action(PcState.EDIT_PUBLISH, "recovery_continue") == AppAccess.RELEASE
assert _app_access_for_action(PcState.BUILD_TEST_AND_REPAIR, "recovery_continue") == AppAccess.TEST_AND_RUN
assert _app_access_for_action(PcState.FIX_VERIFY, "recovery_continue") == AppAccess.TEST_AND_RUN
assert _app_access_for_action(PcState.EDIT_TEST_AFFECTED_PATHS, "recovery_continue") == AppAccess.TEST_AND_RUN
# a non-privileged working step (diagnose) and recovery_restart stay EDIT
assert _app_access_for_action(PcState.FIX_DIAGNOSE, "recovery_continue") == AppAccess.EDIT
assert _app_access_for_action(PcState.BUILD_PUBLISH, "recovery_restart") == AppAccess.EDIT