generated from kgod/ai-review-template
334 lines
11 KiB
Python
334 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app import builder_conversation
|
|
from app.agent import ResumeAgent
|
|
from app.database import Database
|
|
from app.fsm import FSMError
|
|
from app.models import ComponentEventRequest, ComposerMode, MessageRequest, Stage
|
|
|
|
|
|
class _RecordingDatabase:
|
|
def __init__(self) -> None:
|
|
self.in_transaction = False
|
|
self.transaction_modes: list[bool] = []
|
|
self.expected_revision: int | None = None
|
|
self.turns: list[dict[str, object]] = []
|
|
self.write_operations: list[str] = []
|
|
|
|
@contextmanager
|
|
def transaction(self, *, immediate: bool = False):
|
|
assert not self.in_transaction
|
|
self.in_transaction = True
|
|
self.transaction_modes.append(immediate)
|
|
try:
|
|
yield object()
|
|
finally:
|
|
self.in_transaction = False
|
|
|
|
def fetch_session(self, _connection, _session_id):
|
|
return {
|
|
"id": "session-1",
|
|
"stage": Stage.BUILDER_CONVERSATION,
|
|
"revision": 7,
|
|
"profile": {"builder": {}},
|
|
"draft_id": None,
|
|
"resume_id": "resume-1",
|
|
}
|
|
|
|
def fetch_resume(self, _connection, _session_id, *, for_update=False):
|
|
if for_update:
|
|
self.write_operations.append("lock_resume")
|
|
return {"id": "resume-1", "revision": 3, "content": {"sections": []}}
|
|
|
|
def update_session(self, _connection, _session_id, *, stage, profile, expected_revision):
|
|
self.expected_revision = expected_revision
|
|
self.write_operations.append("update_session")
|
|
return {
|
|
"id": "session-1",
|
|
"stage": stage,
|
|
"revision": expected_revision + 1,
|
|
"profile": profile,
|
|
"draft_id": None,
|
|
"resume_id": "resume-1",
|
|
}
|
|
|
|
def insert_turn(self, _connection, **turn):
|
|
self.write_operations.append(f"insert_{turn['role']}_turn")
|
|
self.turns.append(turn)
|
|
return f"turn-{len(self.turns)}"
|
|
|
|
def supersede_active_components(self, _connection, _session_id):
|
|
self.write_operations.append("supersede_components")
|
|
return None
|
|
|
|
def get_turn(self, turn_id):
|
|
return turn_id
|
|
|
|
|
|
class _TrackingDatabase(Database):
|
|
def __init__(self, path: Path) -> None:
|
|
super().__init__(path)
|
|
self.open_transactions = 0
|
|
|
|
@contextmanager
|
|
def transaction(self, *, immediate: bool = False):
|
|
with super().transaction(immediate=immediate) as connection:
|
|
self.open_transactions += 1
|
|
try:
|
|
yield connection
|
|
finally:
|
|
self.open_transactions -= 1
|
|
|
|
|
|
def test_add_message_runs_builder_processing_outside_write_transaction(monkeypatch) -> None:
|
|
database = _RecordingDatabase()
|
|
agent = ResumeAgent.__new__(ResumeAgent)
|
|
agent.database = database
|
|
agent._action_response = lambda _session, turn: SimpleNamespace(
|
|
turn=turn, builder_stream_phases=[]
|
|
)
|
|
|
|
def process_message(_agent, profile, _content, _resume_content):
|
|
assert database.in_transaction is False
|
|
return SimpleNamespace(
|
|
stage=Stage.BUILDER_CONVERSATION,
|
|
profile={**profile, "builder": {"last_stream_phases": ["rewriting"]}},
|
|
turn={
|
|
"role": "assistant",
|
|
"content": "done",
|
|
"composer_mode": "chat",
|
|
"blocks": [],
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(builder_conversation, "process_message", process_message)
|
|
|
|
response = agent.add_message("session-1", MessageRequest(content="补充项目经历"))
|
|
|
|
assert database.transaction_modes == [False, True]
|
|
assert database.expected_revision == 7
|
|
assert [turn["role"] for turn in database.turns] == ["user", "assistant"]
|
|
assert database.write_operations == [
|
|
"lock_resume",
|
|
"update_session",
|
|
"insert_user_turn",
|
|
"supersede_components",
|
|
"insert_assistant_turn",
|
|
]
|
|
assert response.builder_stream_phases == ["rewriting"]
|
|
|
|
|
|
def _create_builder_agent(tmp_path: Path) -> tuple[ResumeAgent, Database]:
|
|
database = Database(tmp_path / "message-transaction.db")
|
|
database.initialize()
|
|
profile = {"builder": {}}
|
|
database.create_session(
|
|
"session-1",
|
|
Stage.BUILDER_CONVERSATION,
|
|
profile,
|
|
{
|
|
"role": "assistant",
|
|
"content": "ready",
|
|
"composer_mode": ComposerMode.CHAT,
|
|
"blocks": [],
|
|
},
|
|
)
|
|
with database.transaction(immediate=True) as connection:
|
|
database.insert_resume(
|
|
connection,
|
|
resume_id="resume-1",
|
|
session_id="session-1",
|
|
idempotency_key=None,
|
|
content={"sections": []},
|
|
)
|
|
database.update_session(
|
|
connection,
|
|
"session-1",
|
|
stage=Stage.BUILDER_CONVERSATION,
|
|
profile=profile,
|
|
resume_id="resume-1",
|
|
)
|
|
|
|
agent = ResumeAgent.__new__(ResumeAgent)
|
|
agent.database = database
|
|
return agent, database
|
|
|
|
|
|
def _transition(profile: dict[str, object]) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
stage=Stage.BUILDER_CONVERSATION,
|
|
profile={**profile, "builder": {"last_stream_phases": ["rewriting"]}},
|
|
turn={
|
|
"role": "assistant",
|
|
"content": "done",
|
|
"composer_mode": ComposerMode.CHAT,
|
|
"blocks": [],
|
|
},
|
|
)
|
|
|
|
|
|
def test_add_message_rejects_concurrent_session_change_without_saving_turns(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
agent, database = _create_builder_agent(tmp_path)
|
|
|
|
def process_message(_agent, profile, _content, _resume_content):
|
|
with database.transaction(immediate=True) as connection:
|
|
database.update_session(
|
|
connection,
|
|
"session-1",
|
|
stage=Stage.BUILDER_CONVERSATION,
|
|
profile={**profile, "concurrent_change": True},
|
|
)
|
|
return _transition(profile)
|
|
|
|
monkeypatch.setattr(builder_conversation, "process_message", process_message)
|
|
|
|
with pytest.raises(FSMError) as exc_info:
|
|
agent.add_message("session-1", MessageRequest(content="补充项目经历"))
|
|
|
|
assert exc_info.value.code == "revision_conflict"
|
|
assert len(database.list_turns("session-1")) == 1
|
|
session = database.get_session("session-1")
|
|
assert session is not None
|
|
assert session["profile"]["concurrent_change"] is True
|
|
|
|
|
|
def test_add_message_rolls_back_session_update_when_resume_changes_during_processing(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
agent, database = _create_builder_agent(tmp_path)
|
|
session_before = database.get_session("session-1")
|
|
assert session_before is not None
|
|
|
|
def process_message(_agent, profile, _content, resume_content):
|
|
with database.transaction(immediate=True) as connection:
|
|
database.update_resume(
|
|
connection,
|
|
"session-1",
|
|
{**resume_content, "concurrent_change": True},
|
|
)
|
|
return _transition(profile)
|
|
|
|
monkeypatch.setattr(builder_conversation, "process_message", process_message)
|
|
|
|
with pytest.raises(FSMError) as exc_info:
|
|
agent.add_message("session-1", MessageRequest(content="补充项目经历"))
|
|
|
|
assert exc_info.value.code == "revision_conflict"
|
|
assert len(database.list_turns("session-1")) == 1
|
|
session_after = database.get_session("session-1")
|
|
assert session_after is not None
|
|
assert session_after["revision"] == session_before["revision"]
|
|
with database.transaction() as connection:
|
|
resume = database.fetch_resume(connection, "session-1")
|
|
assert resume is not None
|
|
assert resume["revision"] == 2
|
|
|
|
|
|
def _component_transition(profile: dict[str, object]) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
stage=Stage.PRIVACY_CONSENT,
|
|
profile={**profile, "privacy_accepted": False},
|
|
lifecycle="dismissed",
|
|
create_draft=False,
|
|
refresh_resume=False,
|
|
resume_content=None,
|
|
polish_description=False,
|
|
propose_anchor_optimization=False,
|
|
suggest_skills=False,
|
|
suggest_target_positions=False,
|
|
generate_profile_summary=False,
|
|
turn={
|
|
"role": "assistant",
|
|
"content": "cancelled",
|
|
"composer_mode": "ui_only",
|
|
"blocks": [],
|
|
},
|
|
)
|
|
|
|
|
|
def _create_component_agent(tmp_path: Path) -> tuple[ResumeAgent, _TrackingDatabase]:
|
|
database = _TrackingDatabase(tmp_path / "component-transaction.db")
|
|
database.initialize()
|
|
database.create_session(
|
|
"session-1",
|
|
Stage.PRIVACY_CONSENT,
|
|
{},
|
|
{
|
|
"role": "assistant",
|
|
"content": "privacy",
|
|
"composer_mode": ComposerMode.UI_ONLY,
|
|
"blocks": [
|
|
{
|
|
"id": "component-1",
|
|
"type": "component",
|
|
"lifecycle": "active",
|
|
"data": {"component_name": "PrivacyConsentCard"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
agent = ResumeAgent.__new__(ResumeAgent)
|
|
agent.database = database
|
|
agent._action_response = lambda session, turn: SimpleNamespace(session=session, turn=turn)
|
|
return agent, database
|
|
|
|
|
|
def test_component_event_processes_transition_outside_write_transaction(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
agent, database = _create_component_agent(tmp_path)
|
|
|
|
def transition(*, profile, **_kwargs):
|
|
assert database.open_transactions == 0
|
|
return _component_transition(profile)
|
|
|
|
monkeypatch.setattr("app.agent.process_component_event", transition)
|
|
|
|
response = agent.component_event(
|
|
"session-1", ComponentEventRequest(component_id="component-1", event="decline")
|
|
)
|
|
|
|
assert response.turn is not None
|
|
with database.transaction() as connection:
|
|
block = database.fetch_block(connection, "session-1", "component-1")
|
|
assert block is not None
|
|
assert block["lifecycle"] == "dismissed"
|
|
|
|
|
|
def test_component_event_rejects_stale_model_result_without_partial_write(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
agent, database = _create_component_agent(tmp_path)
|
|
|
|
def transition(*, profile, **_kwargs):
|
|
with database.transaction(immediate=True) as connection:
|
|
database.update_session(
|
|
connection,
|
|
"session-1",
|
|
stage=Stage.PRIVACY_CONSENT,
|
|
profile={**profile, "concurrent_change": True},
|
|
)
|
|
return _component_transition(profile)
|
|
|
|
monkeypatch.setattr("app.agent.process_component_event", transition)
|
|
|
|
with pytest.raises(FSMError) as exc_info:
|
|
agent.component_event(
|
|
"session-1", ComponentEventRequest(component_id="component-1", event="decline")
|
|
)
|
|
|
|
assert exc_info.value.code == "revision_conflict"
|
|
assert len(database.list_turns("session-1")) == 1
|
|
with database.transaction() as connection:
|
|
block = database.fetch_block(connection, "session-1", "component-1")
|
|
assert block is not None
|
|
assert block["lifecycle"] == "active"
|