Files
resume-agent/backend/tests/test_agent_message_transaction.py
T

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"