Skip to content
Closed
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
8 changes: 7 additions & 1 deletion .github/workflows/ci-e2e-playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ env:
PGPASSWORD: posthog
PGPORT: 5432
OIDC_RSA_PRIVATE_KEY: ${{ secrets.OIDC_RSA_PRIVATE_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
INKEEP_API_KEY: ${{ secrets.INKEEP_API_KEY }}
AZURE_INFERENCE_CREDENTIAL: ${{ secrets.AZURE_INFERENCE_CREDENTIAL }}
AZURE_INFERENCE_ENDPOINT: ${{ secrets.AZURE_INFERENCE_ENDPOINT }}

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
Expand Down Expand Up @@ -329,7 +335,7 @@ jobs:
- name: Start PostHog web & Celery worker
run: |
python manage.py run_autoreload_celery --type=worker &> /tmp/celery.log &
python manage.py runserver 8000 &> /tmp/server.log &
python -m granian --interface asgi posthog.asgi:application --host 0.0.0.0 --port 8000 --log-level debug --workers 1 &> /tmp/server.log &

# Install Playwright browsers while we wait for PostHog to be ready
- name: Install Playwright browsers
Expand Down
20 changes: 9 additions & 11 deletions ee/api/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,15 @@ class ConversationViewSet(TeamAndOrgViewSetMixin, ListModelMixin, RetrieveModelM
lookup_url_kwarg = "conversation"

def safely_get_queryset(self, queryset):
# Only allow access to conversations created by the current user
qs = queryset.filter(user=self.request.user)

# Allow sending messages to any conversation
if self.action == "create":
return qs

# But retrieval must only return conversations from the assistant and with a title.
return qs.filter(
title__isnull=False, type__in=[Conversation.Type.DEEP_RESEARCH, Conversation.Type.ASSISTANT]
).order_by("-updated_at")
# Only single retrieval of a specific conversation is allowed for other users' conversations (if ID known)
if self.action != "retrieve":
queryset = queryset.filter(user=self.request.user)
# For listing or single retrieval, conversations must be from the assistant and have a title
if self.action in ("list", "retrieve"):
queryset = queryset.filter(
title__isnull=False, type__in=[Conversation.Type.DEEP_RESEARCH, Conversation.Type.ASSISTANT]
).order_by("-updated_at")
return queryset

def get_throttles(self):
if (
Expand Down
79 changes: 52 additions & 27 deletions ee/api/test/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def test_add_message_to_existing_conversation(self):
self.assertEqual(str(workflow_inputs.trace_id), trace_id)
self.assertEqual(workflow_inputs.message["content"], "test query")

def test_cant_access_other_users_conversation(self):
def test_cant_start_other_users_conversation(self):
conversation = Conversation.objects.create(user=self.other_user, team=self.team)

self.client.force_login(self.user)
Expand All @@ -167,6 +167,18 @@ def test_cant_access_other_users_conversation(self):

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_cannot_cancel_other_users_conversation(self):
"""Test that cancel action cannot use other user's conversation ID"""
conversation = Conversation.objects.create(
user=self.other_user, team=self.team, title="Other user conversation", type=Conversation.Type.ASSISTANT
)

response = self.client.patch(
f"/api/environments/{self.team.id}/conversations/{conversation.id}/cancel/",
)

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

def test_cant_access_other_teams_conversation(self):
conversation = Conversation.objects.create(user=self.user, team=self.other_team)

Expand Down Expand Up @@ -313,11 +325,12 @@ def test_cancel_already_canceling_conversation(self):
# should be idempotent
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)

def test_cancel_other_users_conversation(self):
def test_cannot_cancel_other_users_conversation_in_same_project(self):
conversation = Conversation.objects.create(user=self.other_user, team=self.team)
response = self.client.patch(
f"/api/environments/{self.team.id}/conversations/{conversation.id}/cancel/",
)
# This should fail because cancel action also filters by user
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

def test_cancel_other_teams_conversation(self):
Expand Down Expand Up @@ -484,58 +497,70 @@ def test_list_only_returns_assistant_conversations_with_title(self):
self.assertIn("messages", results[0])
self.assertIn("status", results[0])

def test_retrieve_conversation_without_title_returns_404(self):
conversation = Conversation.objects.create(
user=self.user, team=self.team, title=None, type=Conversation.Type.ASSISTANT
def test_list_conversations_only_returns_own_conversations(self):
"""Test that listing conversations only returns the current user's conversations"""
# Create conversations for different users
own_conversation = Conversation.objects.create(
user=self.user, team=self.team, title="My conversation", type=Conversation.Type.ASSISTANT
)
Conversation.objects.create(
user=self.other_user, team=self.team, title="Other user conversation", type=Conversation.Type.ASSISTANT
)

with patch("langgraph.graph.state.CompiledStateGraph.aget_state", new_callable=AsyncMock):
response = self.client.get(f"/api/environments/{self.team.id}/conversations/{conversation.id}/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
response = self.client.get(f"/api/environments/{self.team.id}/conversations/")
self.assertEqual(response.status_code, status.HTTP_200_OK)

results = response.json()["results"]
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["id"], str(own_conversation.id))
self.assertEqual(results[0]["title"], "My conversation")

def test_retrieve_non_assistant_conversation_returns_404(self):
def test_retrieve_own_conversation_succeeds(self):
"""Test that user can retrieve their own conversation"""
conversation = Conversation.objects.create(
user=self.user, team=self.team, title="Tool call", type=Conversation.Type.TOOL_CALL
user=self.user, team=self.team, title="My conversation", type=Conversation.Type.ASSISTANT
)

with patch("langgraph.graph.state.CompiledStateGraph.aget_state", new_callable=AsyncMock):
response = self.client.get(f"/api/environments/{self.team.id}/conversations/{conversation.id}/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["id"], str(conversation.id))

def test_conversation_serializer_returns_empty_messages_on_validation_error(self):
def test_retrieve_other_users_conversation_succeeds(self):
"""Test that user can retrieve another user's conversation in the same team"""
conversation = Conversation.objects.create(
user=self.user, team=self.team, title="Conversation with validation error", type=Conversation.Type.ASSISTANT
user=self.other_user, team=self.team, title="Other user conversation", type=Conversation.Type.ASSISTANT
)

# Mock the get_state method to return data that will cause a validation error
with patch("langgraph.graph.state.CompiledStateGraph.aget_state", new_callable=AsyncMock) as mock_get_state:

class MockSnapshot:
values = {"invalid_key": "invalid_value"} # Invalid structure for AssistantState

mock_get_state.return_value = MockSnapshot()

with patch("langgraph.graph.state.CompiledStateGraph.aget_state", new_callable=AsyncMock):
response = self.client.get(f"/api/environments/{self.team.id}/conversations/{conversation.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["id"], str(conversation.id))

# Should return empty messages array when validation fails
self.assertEqual(response.json()["messages"], [])
def test_retrieve_other_teams_conversation_fails(self):
"""Test that user cannot retrieve conversation from another team"""
conversation = Conversation.objects.create(
user=self.user, team=self.other_team, title="Other team conversation", type=Conversation.Type.ASSISTANT
)

with patch("langgraph.graph.state.CompiledStateGraph.aget_state", new_callable=AsyncMock):
response = self.client.get(f"/api/environments/{self.team.id}/conversations/{conversation.id}/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

def test_list_conversations_ordered_by_updated_at(self):
"""Verify conversations are listed with most recently updated first"""
def test_conversations_ordered_by_updated_at_descending(self):
"""Test that conversations are ordered by updated_at in descending order"""
# Create conversations with different update times
conversation1 = Conversation.objects.create(
user=self.user, team=self.team, title="Older conversation", type=Conversation.Type.ASSISTANT
)

conversation2 = Conversation.objects.create(
user=self.user, team=self.team, title="Newer conversation", type=Conversation.Type.ASSISTANT
)

# Set updated_at explicitly to ensure order
conversation1.updated_at = timezone.now() - datetime.timedelta(hours=1)
conversation1.save()

conversation2.updated_at = timezone.now()
conversation2.save()

Expand All @@ -546,7 +571,7 @@ def test_list_conversations_ordered_by_updated_at(self):
results = response.json()["results"]
self.assertEqual(len(results), 2)

# First result should be the newer conversation
# First result should be the newer conversation (most recent first)
self.assertEqual(results[0]["id"], str(conversation2.id))
self.assertEqual(results[0]["title"], "Newer conversation")

Expand Down
7 changes: 5 additions & 2 deletions ee/hogai/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from langgraph.graph.state import CompiledStateGraph
from rest_framework import serializers

from posthog.api.shared import UserBasicSerializer
from posthog.exceptions_capture import capture_exception

from ee.hogai.chat_agent.graph import AssistantGraph
Expand All @@ -15,7 +16,7 @@
from ee.hogai.utils.types.composed import AssistantMaxGraphState
from ee.models.assistant import Conversation

_conversation_fields = ["id", "status", "title", "created_at", "updated_at", "type"]
_conversation_fields = ["id", "status", "title", "user", "created_at", "updated_at", "type"]

MaxGraphType = DeepResearchAssistantGraph | AssistantGraph

Expand All @@ -32,8 +33,10 @@ class Meta:
fields = _conversation_fields
read_only_fields = fields

user = UserBasicSerializer(read_only=True)

class ConversationSerializer(serializers.ModelSerializer):

class ConversationSerializer(ConversationMinimalSerializer):
class Meta:
model = Conversation
fields = [*_conversation_fields, "messages", "has_unsupported_content"]
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-app-posthog-ai--thread--dark.png
Binary file modified frontend/__snapshots__/scenes-app-posthog-ai--thread--light.png
Binary file modified frontend/__snapshots__/scenes-app-posthog-ai--welcome--dark.png
Binary file modified frontend/__snapshots__/scenes-app-posthog-ai--welcome--light.png
60 changes: 59 additions & 1 deletion frontend/src/scenes/max/Max.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
longResponseChunk,
sqlQueryResponseChunk,
} from './__mocks__/chatResponse.mocks'
import { MOCK_DEFAULT_ORGANIZATION } from 'lib/api.mock'
import { MOCK_DEFAULT_BASIC_USER, MOCK_DEFAULT_ORGANIZATION } from 'lib/api.mock'

import { Meta, StoryFn } from '@storybook/react'
import { useActions, useValues } from 'kea'
Expand Down Expand Up @@ -62,6 +62,7 @@ const meta: Meta = {
title: 'Test Conversation',
created_at: '2025-04-29T17:44:21.654307Z',
updated_at: '2025-04-29T17:44:29.184791Z',
user: MOCK_DEFAULT_BASIC_USER,
messages: [],
},
],
Expand Down Expand Up @@ -360,6 +361,62 @@ export const ThreadWithEmptyConversation: StoryFn = () => {
return <Template />
}

export const SharedThread: StoryFn = () => {
const sharedConversationId = 'shared-conversation-123'

useStorybookMocks({
get: {
'/api/environments/:team_id/conversations/': () => [200, conversationList],
[`/api/environments/:team_id/conversations/${sharedConversationId}/`]: () => [
200,
{
id: sharedConversationId,
status: 'idle',
title: 'Shared Analysis: User Retention Insights',
created_at: '2025-01-15T10:30:00.000000Z',
updated_at: '2025-01-15T11:45:00.000000Z',
user: {
id: 1337, // Different user from MOCK_DEFAULT_BASIC_USER
uuid: 'ANOTHER_USER_UUID',
email: '[email protected]',
first_name: 'Another',
last_name: 'User',
},
messages: [
{
id: 'msg-1',
content: 'Can you analyze our user retention patterns and suggest improvements?',
type: 'human',
created_at: '2025-01-15T10:30:00.000000Z',
},
{
id: 'msg-2',
content:
"I'll analyze your user retention patterns. Let me start by examining your data.\n\nBased on the analysis, I can see several key insights:\n\n1. **Day 1 retention**: 45% of users return the next day\n2. **Week 1 retention**: 28% of users are still active after 7 days\n3. **Month 1 retention**: 15% of users remain engaged after 30 days\n\n**Key findings:**\n- Mobile users have 20% higher retention than desktop users\n- Users who complete onboarding have 3x better retention\n- Peak usage occurs between 6-9 PM local time\n\n**Recommendations:**\n1. Improve onboarding completion rate\n2. Implement mobile-first features\n3. Add engagement features for the 6-9 PM window\n4. Create re-engagement campaigns for users who drop off after day 1",
type: 'ai',
created_at: '2025-01-15T11:45:00.000000Z',
},
],
},
],
},
})

const { setConversationId } = useActions(maxLogic({ tabId: 'storybook' }))

useEffect(() => {
// Simulate loading a shared conversation via URL parameter
setConversationId(sharedConversationId)
}, [setConversationId])

return <Template />
}
SharedThread.parameters = {
testOptions: {
waitForLoadersToDisappear: false,
},
}

export const ThreadWithInProgressConversation: StoryFn = () => {
useStorybookMocks({
get: {
Expand Down Expand Up @@ -594,6 +651,7 @@ export const ChatWithUIContext: StoryFn = () => {
title: 'Event Context Test',
created_at: '2025-04-29T17:44:21.654307Z',
updated_at: '2025-04-29T17:44:29.184791Z',
user: MOCK_DEFAULT_BASIC_USER,
messages: [],
},
],
Expand Down
Loading
Loading