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"