Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/sentry/ai_monitoring/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ def clamp_conversation_id_for_storage(conversation_id: str) -> str:
return conversation_id[:CONVERSATION_ID_TRUNCATE_TO] + "..."


# Earliest title source wins (closest to the first user message); project_id breaks ties.
TITLE_ORDER_BY = (F("title_source_timestamp").asc(nulls_last=True), "project_id")


def fetch_conversation_title(
conversation_id: str,
project_ids: Collection[int],
Expand All @@ -105,7 +109,7 @@ def fetch_conversation_title(
title__isnull=False,
)
.exclude(title="")
.order_by(F("title_source_timestamp").asc(nulls_last=True), "project_id")
.order_by(*TITLE_ORDER_BY)
.first()
)

Expand All @@ -127,18 +131,22 @@ def fetch_conversation_titles(
for conversation_id, _ in requested_pairs
}

rows = AIConversationMetadata.objects.filter(
project_id__in={project_id for _, project_id in requested_pairs},
conversation_id_hash__in=conversation_id_by_hash,
title__isnull=False,
).values_list("conversation_id_hash", "project_id", "title")
rows = (
AIConversationMetadata.objects.filter(
project_id__in={project_id for _, project_id in requested_pairs},
conversation_id_hash__in=conversation_id_by_hash,
title__isnull=False,
)
.exclude(title="")
.values_list("conversation_id_hash", "project_id", "title")
)

# The filter matches the cross product of the hashes and the projects, so drop
# any (conversation, project) combination the caller did not ask about.
titles: dict[tuple[str, int], str] = {}
for row_hash, project_id, title in rows:
pair = (conversation_id_by_hash[row_hash], project_id)
if title and pair in requested_pairs:
if pair in requested_pairs:
Comment on lines 146 to +149

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The conversation list view incorrectly picks a title by lowest project_id, while the detail view uses the earliest title_source_timestamp, causing title inconsistency between views.
Severity: MEDIUM

Suggested Fix

Update the list view's data fetching logic, specifically fetch_conversation_titles(), to order results by title_source_timestamp and then project_id. This will align its title selection mechanism with the detail view's fetch_conversation_title() and ensure both views display the same title consistently.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/sentry/ai_monitoring/utils.py#L146-L149

Potential issue: The logic for selecting an AI conversation title is inconsistent
between the list and detail views. The detail view correctly selects the title based on
the earliest `title_source_timestamp`, with `project_id` as a tie-breaker. However, the
list view's implementation in `_first_title()` incorrectly selects the title based on
the lowest `project_id`, ignoring the timestamp. This will cause users to see different
titles for the same conversation in the list view versus the detail view if the project
with the earliest title timestamp is not also the one with the lowest project ID.

Also affects:

  • src/sentry/api/endpoints/organization_ai_conversations.py:592~618

Did we get this right? 👍 / 👎 to inform future reviews.

titles[pair] = title

return titles
Expand Down
26 changes: 18 additions & 8 deletions src/sentry/api/endpoints/organization_ai_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,17 +592,27 @@ def _apply_titles(
conversations_map: dict[str, dict[str, Any]],
project_ids_by_conversation: Mapping[str, set[int]],
) -> None:
"""Attach stored conversation titles, leaving `title` as None when we have none."""
"""Set each conversation's `title` from storage when present.

On lookup failure, log and leave titles unset so the list response still succeeds.
"""
sorted_project_ids = {
conv_id: sorted(project_ids_by_conversation.get(conv_id, ()))
for conv_id in conversations_map
}
titles = fetch_conversation_titles(
[
(conv_id, project_id)
for conv_id, project_ids in sorted_project_ids.items()
for project_id in project_ids
]
)
pairs = [
(conv_id, project_id)
for conv_id, project_ids in sorted_project_ids.items()
for project_id in project_ids
]
try:
titles = fetch_conversation_titles(pairs)
except Exception:
logger.exception(
"Failed to resolve titles for AI conversations",
extra={"project_ids": sorted({project_id for _, project_id in pairs})},
)
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List title selection still misaligned

Medium Severity

The list path still resolves titles through _first_title, which picks the lowest project_id with a stored title. Detail uses TITLE_ORDER_BY (title_source_timestamp, then project_id), so multi-project conversations can still show different titles on list vs detail.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87c8f81. Configure here.

for conv_id, conversation in conversations_map.items():
conversation["title"] = _first_title(titles, conv_id, sorted_project_ids[conv_id])
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json # noqa: S003
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock, patch
from uuid import uuid4

from django.urls import reverse
Expand Down Expand Up @@ -1597,3 +1598,27 @@ def test_title_found_when_only_higher_project_id_has_one(self) -> None:
assert response.status_code == 200, response.data
assert len(response.data) == 1
assert response.data[0]["title"] == "Higher project id title"

@patch(
"sentry.api.endpoints.organization_ai_conversations.fetch_conversation_titles",
side_effect=Exception("metadata unavailable"),
)
def test_title_lookup_failure_does_not_break_list(
self, mock_fetch_conversation_titles: MagicMock
) -> None:
now = before_now(days=25).replace(microsecond=0)
conversation_id = uuid4().hex

self._store_conversation_span(conversation_id, now)

query = {
"project": [self.project.id],
"start": (now - timedelta(hours=1)).isoformat(),
"end": (now + timedelta(hours=1)).isoformat(),
}

response = self.do_request(query)
assert response.status_code == 200, response.data
assert len(response.data) == 1
assert response.data[0]["title"] is None
assert mock_fetch_conversation_titles.call_count == 1
Loading