fix(api): dedup EndUser in plugin get_user by session_id for Reverse Invocation (#36742)

This commit is contained in:
shuntaro okuma
2026-06-01 09:57:29 +09:00
committed by GitHub
parent df6b5be50a
commit e7be04fd58
2 changed files with 49 additions and 1 deletions
+18
View File
@@ -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(
@@ -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")