diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index 5ef21e63..ae7c70ba 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -168,6 +168,29 @@ async def start_onboarding( .first(session) ) if onboarding: + last_user_content: str | None = None + messages = onboarding.messages or [] + if messages: + last_message = messages[-1] + if isinstance(last_message, dict): + last_role = last_message.get("role") + content = last_message.get("content") + if last_role == "user" and isinstance(content, str) and content: + last_user_content = content + + if last_user_content: + # Retrigger the agent when the session is waiting on a response. + dispatcher = BoardOnboardingMessagingService(session) + await dispatcher.dispatch_answer( + board=board, + onboarding=onboarding, + answer_text=last_user_content, + correlation_id=f"onboarding.resume:{board.id}:{onboarding.id}", + ) + onboarding.updated_at = utcnow() + session.add(onboarding) + await session.commit() + await session.refresh(onboarding) return onboarding dispatcher = BoardOnboardingMessagingService(session) diff --git a/backend/tests/test_board_onboarding_start_api.py b/backend/tests/test_board_onboarding_start_api.py new file mode 100644 index 00000000..5edc5c57 --- /dev/null +++ b/backend/tests/test_board_onboarding_start_api.py @@ -0,0 +1,175 @@ +# ruff: noqa: INP001, S101 +"""Tests for board onboarding start-session restart behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any +from uuid import uuid4 + +import pytest + +from app.api import board_onboarding +from app.core.time import utcnow +from app.models.board_onboarding import BoardOnboardingSession +from app.schemas.board_onboarding import BoardOnboardingStart + + +@dataclass +class _FakeScalarResult: + value: object | None + + def first(self) -> object | None: + return self.value + + +@dataclass +class _FakeSession: + first_value: object | None + added: list[object] = field(default_factory=list) + committed: int = 0 + refreshed: list[object] = field(default_factory=list) + + async def exec(self, _statement: object) -> _FakeScalarResult: + return _FakeScalarResult(self.first_value) + + def add(self, value: object) -> None: + self.added.append(value) + + async def commit(self) -> None: + self.committed += 1 + + async def refresh(self, value: object) -> None: + self.refreshed.append(value) + + +@pytest.mark.asyncio +async def test_start_onboarding_redispatches_when_last_message_is_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board_id = uuid4() + onboarding = BoardOnboardingSession( + board_id=board_id, + session_key="session-key", + status="active", + messages=[ + { + "role": "user", + "content": "I prefer concise updates.", + "timestamp": utcnow().isoformat(), + }, + ], + ) + session: Any = _FakeSession(first_value=onboarding) + board = SimpleNamespace(id=board_id, name="Roadmap", description="Build v1") + captured_calls: list[dict[str, object]] = [] + + class _FakeMessagingService: + def __init__(self, _session: object) -> None: + self._session = _session + + async def dispatch_answer( + self, + *, + board: object, + onboarding: object, + answer_text: str, + correlation_id: str, + ) -> None: + captured_calls.append( + { + "board": board, + "onboarding": onboarding, + "answer_text": answer_text, + "correlation_id": correlation_id, + }, + ) + + monkeypatch.setattr( + board_onboarding, + "BoardOnboardingMessagingService", + _FakeMessagingService, + ) + + before = onboarding.updated_at + result = await board_onboarding.start_onboarding( + _payload=BoardOnboardingStart(), + board=board, + session=session, + ) + + assert result is onboarding + assert len(captured_calls) == 1 + assert captured_calls[0]["answer_text"] == "I prefer concise updates." + assert str(captured_calls[0]["correlation_id"]).startswith("onboarding.resume:") + assert onboarding.updated_at >= before + assert session.added == [onboarding] + assert session.committed == 1 + assert session.refreshed == [onboarding] + + +@pytest.mark.asyncio +async def test_start_onboarding_does_not_redispatch_when_waiting_for_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board_id = uuid4() + onboarding = BoardOnboardingSession( + board_id=board_id, + session_key="session-key", + status="active", + messages=[ + { + "role": "user", + "content": "I prefer concise updates.", + "timestamp": utcnow().isoformat(), + }, + { + "role": "assistant", + "content": '{"question":"What is your timezone?","options":[{"id":"1","label":"UTC"}]}', + "timestamp": utcnow().isoformat(), + }, + ], + ) + session: Any = _FakeSession(first_value=onboarding) + board = SimpleNamespace(id=board_id, name="Roadmap", description="Build v1") + captured_calls: list[dict[str, object]] = [] + + class _FakeMessagingService: + def __init__(self, _session: object) -> None: + self._session = _session + + async def dispatch_answer( + self, + *, + board: object, + onboarding: object, + answer_text: str, + correlation_id: str, + ) -> None: + captured_calls.append( + { + "board": board, + "onboarding": onboarding, + "answer_text": answer_text, + "correlation_id": correlation_id, + }, + ) + + monkeypatch.setattr( + board_onboarding, + "BoardOnboardingMessagingService", + _FakeMessagingService, + ) + + result = await board_onboarding.start_onboarding( + _payload=BoardOnboardingStart(), + board=board, + session=session, + ) + + assert result is onboarding + assert captured_calls == [] + assert session.added == [] + assert session.committed == 0 + assert session.refreshed == []