Skip to content

Commit fa79678

Browse files
committed
2 parents 96746ea + 8780bf7 commit fa79678

File tree

3 files changed

+129
-5
lines changed

3 files changed

+129
-5
lines changed

docs/ui/experimental.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ The dev server proxies `/api/v1` to `VITE_API_URL` (defaults to http://localhost
2020
VITE_API_URL=http://localhost:8000 npm run dev
2121
```
2222

23+
### Run UI end-to-end tests (Playwright)
24+
```bash
25+
cd ui
26+
npm install
27+
npm run e2e
28+
```
29+
30+
The tests are located in `ui/e2e/`.
31+
32+
2333
### Notes
2434
- Compose sets `VITE_API_URL=http://sre-agent:8000` for in‑container proxying
2535
- API endpoints are under `/api/v1` (e.g., `/api/v1/health`, `/api/v1/metrics`, `/api/v1/tasks`)

redis_sre_agent/core/threads.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,10 @@ async def delete_thread(self, thread_id: str) -> bool:
744744
# Remove from indices
745745
await self._remove_from_thread_index(thread_id, user_id)
746746

747+
# Remove the hash document used by the threads search index
748+
search_doc_key = f"{SRE_THREADS_INDEX}:{thread_id}"
749+
await client.delete(search_doc_key)
750+
747751
logger.info(f"Deleted thread {thread_id}")
748752
return True
749753

@@ -887,17 +891,17 @@ async def cancel_thread(*, thread_id: str, redis_client=None) -> Dict[str, Any]:
887891

888892

889893
async def delete_thread(*, thread_id: str, redis_client=None) -> Dict[str, Any]:
890-
"""Permanently delete a thread."""
894+
"""Permanently delete a thread.
895+
896+
This operation is idempotent: it will succeed even if the thread has
897+
already been deleted or never existed, as long as Redis is reachable.
898+
"""
891899
if redis_client is None:
892900
from redis_sre_agent.core.redis import get_redis_client as _get
893901

894902
redis_client = _get()
895903

896904
thread_manager = ThreadManager(redis_client=redis_client)
897-
thread_state = await thread_manager.get_thread(thread_id)
898-
if not thread_state:
899-
raise ValueError(f"Thread {thread_id} not found")
900-
901905
success = await thread_manager.delete_thread(thread_id)
902906
if not success:
903907
raise RuntimeError(f"Failed to delete thread {thread_id}")
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Integration test for thread deletion and Redis data layout.
2+
3+
This test exercises the new Threads API against a real Redis instance
4+
(provided by the testcontainers-backed redis_container fixture).
5+
It creates a thread via the HTTP API, attempts to delete it, and inspects
6+
thread-related keys (including the search-index hash) before and after
7+
deletion.
8+
"""
9+
10+
import pytest
11+
from httpx import ASGITransport, AsyncClient
12+
13+
from redis_sre_agent.core.keys import RedisKeys
14+
from redis_sre_agent.core.redis import SRE_THREADS_INDEX
15+
16+
17+
@pytest.mark.integration
18+
@pytest.mark.asyncio
19+
async def test_thread_delete_integration(async_redis_client, redis_container):
20+
"""Create a thread via API, delete it, and inspect Redis keys.
21+
22+
This exercises the real delete path and asserts that it cleans up both
23+
the per-thread keys and the hash document backing the threads search
24+
index.
25+
"""
26+
if not redis_container:
27+
pytest.skip("Integration tests not enabled")
28+
29+
# Import the real FastAPI app only after redis_container has configured
30+
# REDIS_URL and created indices in the test Redis.
31+
from redis_sre_agent.api.app import app
32+
33+
# ------------------------------------------------------------------
34+
# 1. Create a thread via the Threads API
35+
# ------------------------------------------------------------------
36+
create_payload = {
37+
"user_id": "test-user",
38+
"session_id": "test-session",
39+
"subject": "Delete me",
40+
"messages": [{"role": "user", "content": "Please investigate delete behaviour."}],
41+
}
42+
43+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
44+
create_resp = await client.post("/api/v1/threads", json=create_payload)
45+
46+
assert create_resp.status_code == 201
47+
create_body = create_resp.json()
48+
thread_id = create_body["thread_id"]
49+
50+
# ------------------------------------------------------------------
51+
# 2. Inspect Redis keys before deletion
52+
# ------------------------------------------------------------------
53+
thread_key_pattern = f"sre:thread:{thread_id}:*"
54+
55+
before_keys = set()
56+
cursor = 0
57+
while True:
58+
cursor, batch = await async_redis_client.scan(cursor=cursor, match=thread_key_pattern)
59+
if batch:
60+
before_keys.update(batch)
61+
if cursor == 0:
62+
break
63+
64+
before_keys_str = {
65+
k.decode("utf-8") if isinstance(k, (bytes, bytearray)) else str(k) for k in before_keys
66+
}
67+
68+
# We expect at least the metadata key to be present for a fresh thread.
69+
assert RedisKeys.thread_metadata(thread_id) in before_keys_str
70+
71+
search_hash_key = f"{SRE_THREADS_INDEX}:{thread_id}"
72+
search_hash_exists_before = bool(await async_redis_client.exists(search_hash_key))
73+
# The search hash is written by ThreadManager._upsert_thread_search_doc during creation.
74+
assert search_hash_exists_before
75+
76+
# ------------------------------------------------------------------
77+
# 3. Attempt to delete the thread via the API
78+
# ------------------------------------------------------------------
79+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
80+
delete_resp = await client.delete(f"/api/v1/threads/{thread_id}")
81+
delete_resp_second = await client.delete(f"/api/v1/threads/{thread_id}")
82+
83+
# Delete should now be idempotent and always return 204 even if the
84+
# underlying keys have already been removed.
85+
assert delete_resp.status_code == 204
86+
assert delete_resp_second.status_code == 204
87+
88+
# ------------------------------------------------------------------
89+
# 4. Inspect Redis keys after deletion
90+
# ------------------------------------------------------------------
91+
after_keys = set()
92+
cursor = 0
93+
while True:
94+
cursor, batch = await async_redis_client.scan(cursor=cursor, match=thread_key_pattern)
95+
if batch:
96+
after_keys.update(batch)
97+
if cursor == 0:
98+
break
99+
100+
after_keys_str = {
101+
k.decode("utf-8") if isinstance(k, (bytes, bytearray)) else str(k) for k in after_keys
102+
}
103+
104+
# The per-thread metadata key is removed by the existing delete path.
105+
assert RedisKeys.thread_metadata(thread_id) not in after_keys_str
106+
107+
# After deletion, the hash that backs the threads search index should also
108+
# be removed so the thread no longer appears in search results.
109+
search_hash_exists_after = bool(await async_redis_client.exists(search_hash_key))
110+
assert not search_hash_exists_after

0 commit comments

Comments
 (0)