feat: add conversation deletion endpoint (soft delete) [LET-7286] (#9230)

* 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 <sarahwooders@users.noreply.github.com>

🤖 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* 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 <sarahwooders@users.noreply.github.com>

🤖 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* 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 <noreply@letta.com>

---------

Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: Sarah Wooders <sarahwooders@gmail.com>
This commit is contained in:
github-actions[bot]
2026-02-20 15:28:25 -08:00
committed by Caren Thomas
parent 9c8589a687
commit ba67621e1b
5 changed files with 267 additions and 8 deletions

View File

@@ -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-<uuid4>'",
"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-<uuid4>'"
}
],
"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": {

View File

@@ -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"}})
]

View File

@@ -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

View File

@@ -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."""

View File

@@ -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."""