From ba67621e1bd4ab4f791d91cdbbfc96632fcab8f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:28:25 -0800 Subject: [PATCH] feat: add conversation deletion endpoint (soft delete) [LET-7286] (#9230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add conversation deletion endpoint (soft delete) [LET-7286] - Add DELETE /conversations/{conversation_id} endpoint - Filter soft-deleted conversations from list operations - Add check_is_deleted=True to update/delete operations Co-authored-by: Sarah Wooders 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * feat: add tests, update SDK and stainless for delete conversation - Add 5 integration tests for DELETE conversation endpoint - Run stage-api to regenerate OpenAPI spec and SDK - Add delete method to conversations in stainless.yml Co-authored-by: Sarah Wooders 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * test: add manager-level tests for conversation soft delete [LET-7286] - test_delete_conversation_removes_from_list - test_delete_conversation_double_delete_raises - test_update_deleted_conversation_raises - test_delete_conversation_excluded_from_summary_search 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta --------- Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Letta Co-authored-by: Sarah Wooders --- fern/openapi.json | 46 ++++++++ .../rest_api/routers/v1/conversations.py | 24 +++- letta/services/conversation_manager.py | 13 +-- tests/integration_test_conversations_sdk.py | 88 +++++++++++++++ tests/managers/test_conversation_manager.py | 104 ++++++++++++++++++ 5 files changed, 267 insertions(+), 8 deletions(-) diff --git a/fern/openapi.json b/fern/openapi.json index fdf98425..98f1b815 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -8922,6 +8922,52 @@ } } } + }, + "delete": { + "tags": ["conversations"], + "summary": "Delete Conversation", + "description": "Delete a conversation (soft delete).\n\nThis marks the conversation as deleted but does not permanently remove it from the database.\nThe conversation will no longer appear in list operations.\nAny isolated blocks associated with the conversation will be permanently deleted.", + "operationId": "delete_conversation", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 41, + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], + "title": "Conversation Id" + }, + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/v1/conversations/{conversation_id}/messages": { diff --git a/letta/server/rest_api/routers/v1/conversations.py b/letta/server/rest_api/routers/v1/conversations.py index fef31b06..d52f54bd 100644 --- a/letta/server/rest_api/routers/v1/conversations.py +++ b/letta/server/rest_api/routers/v1/conversations.py @@ -60,7 +60,9 @@ async def create_conversation( @router.get("/", response_model=List[Conversation], operation_id="list_conversations") async def list_conversations( - agent_id: Optional[str] = Query(None, description="The agent ID to list conversations for (optional - returns all conversations if not provided)"), + agent_id: Optional[str] = Query( + None, description="The agent ID to list conversations for (optional - returns all conversations if not provided)" + ), limit: int = Query(50, description="Maximum number of conversations to return"), after: Optional[str] = Query(None, description="Cursor for pagination (conversation ID)"), summary_search: Optional[str] = Query(None, description="Search for text within conversation summaries"), @@ -108,6 +110,26 @@ async def update_conversation( ) +@router.delete("/{conversation_id}", response_model=None, operation_id="delete_conversation") +async def delete_conversation( + conversation_id: ConversationId, + server: SyncServer = Depends(get_letta_server), + headers: HeaderParams = Depends(get_headers), +): + """ + Delete a conversation (soft delete). + + This marks the conversation as deleted but does not permanently remove it from the database. + The conversation will no longer appear in list operations. + Any isolated blocks associated with the conversation will be permanently deleted. + """ + actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) + await conversation_manager.delete_conversation( + conversation_id=conversation_id, + actor=actor, + ) + + ConversationMessagesResponse = Annotated[ List[LettaMessageUnion], Field(json_schema_extra={"type": "array", "items": {"$ref": "#/components/schemas/LettaMessageUnion"}}) ] diff --git a/letta/services/conversation_manager.py b/letta/services/conversation_manager.py index 7d374092..41111623 100644 --- a/letta/services/conversation_manager.py +++ b/letta/services/conversation_manager.py @@ -129,20 +129,16 @@ class ConversationManager: # Build where conditions conditions = [ ConversationModel.organization_id == actor.organization_id, + ConversationModel.is_deleted == False, ConversationModel.summary.isnot(None), ConversationModel.summary.contains(summary_search), ] - + # Add agent_id filter if provided if agent_id is not None: conditions.append(ConversationModel.agent_id == agent_id) - stmt = ( - select(ConversationModel) - .where(and_(*conditions)) - .order_by(ConversationModel.created_at.desc()) - .limit(limit) - ) + stmt = select(ConversationModel).where(and_(*conditions)).order_by(ConversationModel.created_at.desc()).limit(limit) if after: # Add cursor filtering @@ -162,6 +158,7 @@ class ConversationManager: db_session=session, actor=actor, agent_id=agent_id, + is_deleted=False, limit=limit, after=after, ascending=False, @@ -182,6 +179,7 @@ class ConversationManager: db_session=session, identifier=conversation_id, actor=actor, + check_is_deleted=True, ) # Set attributes on the model @@ -209,6 +207,7 @@ class ConversationManager: db_session=session, identifier=conversation_id, actor=actor, + check_is_deleted=True, ) # Get isolated blocks before modifying conversation diff --git a/tests/integration_test_conversations_sdk.py b/tests/integration_test_conversations_sdk.py index 8ba154b4..129e0ecf 100644 --- a/tests/integration_test_conversations_sdk.py +++ b/tests/integration_test_conversations_sdk.py @@ -567,6 +567,94 @@ class TestConversationsSDK: assert first_message_id not in [m.id for m in messages_after] +class TestConversationDelete: + """Tests for the conversation delete endpoint.""" + + def test_delete_conversation(self, client: Letta, agent, server_url: str): + """Test soft deleting a conversation.""" + # Create a conversation + conversation = client.conversations.create(agent_id=agent.id) + assert conversation.id is not None + + # Delete it via REST endpoint + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" + + # Verify it's no longer accessible + response = requests.get( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 404, f"Expected 404 for deleted conversation, got {response.status_code}" + + def test_delete_conversation_removes_from_list(self, client: Letta, agent, server_url: str): + """Test that deleted conversations don't appear in list.""" + # Create two conversations + conv1 = client.conversations.create(agent_id=agent.id) + conv2 = client.conversations.create(agent_id=agent.id) + + # Verify both appear in list + conversations = client.conversations.list(agent_id=agent.id) + conv_ids = [c.id for c in conversations] + assert conv1.id in conv_ids + assert conv2.id in conv_ids + + # Delete one + response = requests.delete( + f"{server_url}/v1/conversations/{conv1.id}", + ) + assert response.status_code == 200 + + # Verify only the non-deleted one appears in list + conversations = client.conversations.list(agent_id=agent.id) + conv_ids = [c.id for c in conversations] + assert conv1.id not in conv_ids, "Deleted conversation should not appear in list" + assert conv2.id in conv_ids, "Non-deleted conversation should still appear" + + def test_delete_conversation_not_found(self, client: Letta, agent, server_url: str): + """Test that deleting a non-existent conversation returns 404.""" + fake_id = "conv-00000000-0000-0000-0000-000000000000" + response = requests.delete( + f"{server_url}/v1/conversations/{fake_id}", + ) + assert response.status_code == 404 + + def test_delete_conversation_double_delete(self, client: Letta, agent, server_url: str): + """Test that deleting an already-deleted conversation returns 404.""" + # Create and delete a conversation + conversation = client.conversations.create(agent_id=agent.id) + + # First delete should succeed + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 200 + + # Second delete should return 404 + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 404, "Double delete should return 404" + + def test_update_deleted_conversation_fails(self, client: Letta, agent, server_url: str): + """Test that updating a deleted conversation returns 404.""" + # Create and delete a conversation + conversation = client.conversations.create(agent_id=agent.id) + + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 200 + + # Try to update the deleted conversation + response = requests.patch( + f"{server_url}/v1/conversations/{conversation.id}", + json={"summary": "Updated summary"}, + ) + assert response.status_code == 404, "Updating deleted conversation should return 404" + + class TestConversationCompact: """Tests for the conversation compact (summarization) endpoint.""" diff --git a/tests/managers/test_conversation_manager.py b/tests/managers/test_conversation_manager.py index 03329a9a..acb807f8 100644 --- a/tests/managers/test_conversation_manager.py +++ b/tests/managers/test_conversation_manager.py @@ -166,6 +166,110 @@ async def test_delete_conversation(conversation_manager, server: SyncServer, sar ) +@pytest.mark.asyncio +async def test_delete_conversation_removes_from_list(conversation_manager, server: SyncServer, sarah_agent, default_user): + """Test that soft-deleted conversations are excluded from list results.""" + # Create two conversations + conv1 = await conversation_manager.create_conversation( + agent_id=sarah_agent.id, + conversation_create=CreateConversation(summary="Keep me"), + actor=default_user, + ) + conv2 = await conversation_manager.create_conversation( + agent_id=sarah_agent.id, + conversation_create=CreateConversation(summary="Delete me"), + actor=default_user, + ) + + # Delete one + await conversation_manager.delete_conversation( + conversation_id=conv2.id, + actor=default_user, + ) + + # List should only return the non-deleted conversation + conversations = await conversation_manager.list_conversations( + agent_id=sarah_agent.id, + actor=default_user, + ) + conv_ids = [c.id for c in conversations] + assert conv1.id in conv_ids + assert conv2.id not in conv_ids + + +@pytest.mark.asyncio +async def test_delete_conversation_double_delete_raises(conversation_manager, server: SyncServer, sarah_agent, default_user): + """Test that deleting an already-deleted conversation raises NoResultFound.""" + created = await conversation_manager.create_conversation( + agent_id=sarah_agent.id, + conversation_create=CreateConversation(summary="Delete me twice"), + actor=default_user, + ) + + await conversation_manager.delete_conversation( + conversation_id=created.id, + actor=default_user, + ) + + # Second delete should raise + with pytest.raises(NoResultFound): + await conversation_manager.delete_conversation( + conversation_id=created.id, + actor=default_user, + ) + + +@pytest.mark.asyncio +async def test_update_deleted_conversation_raises(conversation_manager, server: SyncServer, sarah_agent, default_user): + """Test that updating a soft-deleted conversation raises NoResultFound.""" + created = await conversation_manager.create_conversation( + agent_id=sarah_agent.id, + conversation_create=CreateConversation(summary="Original"), + actor=default_user, + ) + + await conversation_manager.delete_conversation( + conversation_id=created.id, + actor=default_user, + ) + + with pytest.raises(NoResultFound): + await conversation_manager.update_conversation( + conversation_id=created.id, + conversation_update=UpdateConversation(summary="Should fail"), + actor=default_user, + ) + + +@pytest.mark.asyncio +async def test_delete_conversation_excluded_from_summary_search(conversation_manager, server: SyncServer, sarah_agent, default_user): + """Test that soft-deleted conversations are excluded from summary search results.""" + await conversation_manager.create_conversation( + agent_id=sarah_agent.id, + conversation_create=CreateConversation(summary="alpha search term"), + actor=default_user, + ) + to_delete = await conversation_manager.create_conversation( + agent_id=sarah_agent.id, + conversation_create=CreateConversation(summary="alpha deleted term"), + actor=default_user, + ) + + await conversation_manager.delete_conversation( + conversation_id=to_delete.id, + actor=default_user, + ) + + results = await conversation_manager.list_conversations( + agent_id=sarah_agent.id, + actor=default_user, + summary_search="alpha", + ) + result_ids = [c.id for c in results] + assert to_delete.id not in result_ids + assert len(results) == 1 + + @pytest.mark.asyncio async def test_conversation_isolation_by_agent(conversation_manager, server: SyncServer, sarah_agent, charles_agent, default_user): """Test that conversations are isolated by agent."""