diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index 2f309262cb..ad369660d9 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -45,6 +45,15 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: .limit(1) ) else: + # Try id first (preserves the original "explicit end-user + # id → that specific user" semantics for callers that pass + # a known EndUser.id). Fall back to session_id so daemon- + # supplied session UUIDs dedup against the row created on + # the first Reverse Invocation call — without this, an + # id-only lookup never matched (create writes user_id to + # session_id, id is auto-generated) and a fresh EndUser + # was created per call, breaking multi-turn chat + # continuation (see #36736). user_model = session.scalar( select(EndUser) .where( @@ -53,6 +62,15 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: ) .limit(1) ) + if user_model is None: + user_model = session.scalar( + select(EndUser) + .where( + EndUser.session_id == user_id, + EndUser.tenant_id == tenant_id, + ) + .limit(1) + ) if not user_model: user_model = EndUser( diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index 598677faff..cc581c0c75 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -94,9 +94,39 @@ class TestGetUser: # Assert assert result == mock_new_user mock_session.get.assert_not_called() - mock_session.scalar.assert_called_once() + # Non-anonymous miss now tries id, then session_id fallback (see + # #36736); both miss in this tenant → fall through to create. + assert mock_session.scalar.call_count == 2 mock_session.add.assert_called_once_with(mock_new_user) + @patch("controllers.inner_api.plugin.wraps.select") + @patch("controllers.inner_api.plugin.wraps.EndUser") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") + @patch("controllers.inner_api.plugin.wraps.db") + def test_should_return_existing_user_by_session_id_fallback_for_non_anonymous( + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + ): + """Non-anonymous user_id misses on EndUser.id but hits on + EndUser.session_id — this is the plugin-daemon Reverse Invocation + case where the daemon sends a stable session-derived UUID that + was written into session_id on the first call. See #36736. + """ + # Arrange + mock_user = MagicMock() + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + # First scalar (id lookup) returns None, second (session_id fallback) hits. + mock_session.scalar.side_effect = [None, mock_user] + + # Act + with app.app_context(): + result = get_user("tenant123", "daemon-session-uuid") + + # Assert + assert result == mock_user + assert mock_session.scalar.call_count == 2 + mock_session.add.assert_not_called() + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker")