kairo-code 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. kairo/backend/api/agents.py +337 -16
  2. kairo/backend/app.py +84 -4
  3. kairo/backend/config.py +4 -2
  4. kairo/backend/models/agent.py +216 -2
  5. kairo/backend/models/api_key.py +4 -1
  6. kairo/backend/models/task.py +31 -0
  7. kairo/backend/models/user_provider_key.py +26 -0
  8. kairo/backend/schemas/agent.py +249 -2
  9. kairo/backend/schemas/api_key.py +3 -0
  10. kairo/backend/services/agent/__init__.py +52 -0
  11. kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
  12. kairo/backend/services/agent/agent_alerts_service.py +201 -0
  13. kairo/backend/services/agent/agent_commands_service.py +142 -0
  14. kairo/backend/services/agent/agent_crud_service.py +150 -0
  15. kairo/backend/services/agent/agent_events_service.py +103 -0
  16. kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
  17. kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
  18. kairo/backend/services/agent/agent_metrics_service.py +259 -0
  19. kairo/backend/services/agent/agent_service.py +315 -0
  20. kairo/backend/services/agent/agent_setup_service.py +180 -0
  21. kairo/backend/services/agent/constants.py +28 -0
  22. kairo/backend/services/agent_service.py +18 -102
  23. kairo/backend/services/api_key_service.py +23 -3
  24. kairo/backend/services/byok_service.py +204 -0
  25. kairo/backend/services/chat_service.py +398 -63
  26. kairo/backend/services/deep_search_service.py +159 -0
  27. kairo/backend/services/email_service.py +418 -19
  28. kairo/backend/services/few_shot_service.py +223 -0
  29. kairo/backend/services/post_processor.py +261 -0
  30. kairo/backend/services/rag_service.py +150 -0
  31. kairo/backend/services/task_service.py +119 -0
  32. kairo/backend/tests/__init__.py +1 -0
  33. kairo/backend/tests/e2e/__init__.py +1 -0
  34. kairo/backend/tests/e2e/agents/__init__.py +1 -0
  35. kairo/backend/tests/e2e/agents/conftest.py +389 -0
  36. kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
  37. kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
  38. kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
  39. kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
  40. kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
  41. kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
  42. kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
  43. kairo/migrations/versions/010_agent_dashboard.py +246 -0
  44. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
  45. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
  46. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
  47. kairo_migrations/env.py +92 -0
  48. kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
  49. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
  50. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,455 @@
1
+ """
2
+ End-to-end tests for Agent CRUD operations.
3
+
4
+ Tests cover:
5
+ - Create agent with all fields
6
+ - List agents (empty, with agents)
7
+ - Get agent by ID
8
+ - Update agent fields
9
+ - Delete agent (soft delete)
10
+ - 404 for non-existent agent
11
+ """
12
+
13
+ import pytest
14
+ import uuid
15
+ from httpx import AsyncClient
16
+
17
+ from backend.models.agent import Agent
18
+
19
+
20
+ class TestCreateAgent:
21
+ """Tests for POST /api/agents/"""
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_create_agent_with_all_fields(
25
+ self, client: AsyncClient, auth_headers: dict
26
+ ):
27
+ """Should create an agent with all optional fields specified."""
28
+ payload = {
29
+ "name": "My Production Agent",
30
+ "description": "Handles customer support queries",
31
+ "system_prompt": "You are a helpful customer support assistant.",
32
+ "model_preference": "nyx",
33
+ "agent_type": "sdk",
34
+ "tools": ["web_search", "file_read"],
35
+ }
36
+
37
+ response = await client.post(
38
+ "/api/agents/", json=payload, headers=auth_headers
39
+ )
40
+
41
+ assert response.status_code == 200
42
+ data = response.json()
43
+ assert data["name"] == payload["name"]
44
+ assert data["description"] == payload["description"]
45
+ assert data["model_preference"] == payload["model_preference"]
46
+ assert data["agent_type"] == payload["agent_type"]
47
+ assert data["state"] == "created"
48
+ assert data["status"] == "offline"
49
+ assert "id" in data
50
+ assert "created_at" in data
51
+ assert "updated_at" in data
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_create_agent_minimal_fields(
55
+ self, client: AsyncClient, auth_headers: dict
56
+ ):
57
+ """Should create an agent with only required fields."""
58
+ payload = {"name": "Minimal Agent"}
59
+
60
+ response = await client.post(
61
+ "/api/agents/", json=payload, headers=auth_headers
62
+ )
63
+
64
+ assert response.status_code == 200
65
+ data = response.json()
66
+ assert data["name"] == "Minimal Agent"
67
+ assert data["model_preference"] == "nyx" # default
68
+ assert data["agent_type"] == "sdk" # default
69
+ assert data["description"] is None
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_create_agent_validation_error_empty_name(
73
+ self, client: AsyncClient, auth_headers: dict
74
+ ):
75
+ """Should reject agent creation with empty name."""
76
+ payload = {"name": ""}
77
+
78
+ response = await client.post(
79
+ "/api/agents/", json=payload, headers=auth_headers
80
+ )
81
+
82
+ assert response.status_code == 422
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_create_agent_validation_error_name_too_long(
86
+ self, client: AsyncClient, auth_headers: dict
87
+ ):
88
+ """Should reject agent creation with name exceeding 100 chars."""
89
+ payload = {"name": "x" * 101}
90
+
91
+ response = await client.post(
92
+ "/api/agents/", json=payload, headers=auth_headers
93
+ )
94
+
95
+ assert response.status_code == 422
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_create_agent_invalid_agent_type(
99
+ self, client: AsyncClient, auth_headers: dict
100
+ ):
101
+ """Should reject agent creation with invalid agent_type."""
102
+ payload = {"name": "Test Agent", "agent_type": "invalid"}
103
+
104
+ response = await client.post(
105
+ "/api/agents/", json=payload, headers=auth_headers
106
+ )
107
+
108
+ assert response.status_code == 422
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_create_agent_requires_auth(self, client: AsyncClient):
112
+ """Should require authentication to create agent."""
113
+ payload = {"name": "Test Agent"}
114
+
115
+ response = await client.post("/api/agents/", json=payload)
116
+
117
+ assert response.status_code == 403 # No auth header
118
+
119
+
120
+ class TestListAgents:
121
+ """Tests for GET /api/agents/"""
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_list_agents_empty(
125
+ self, client: AsyncClient, auth_headers: dict
126
+ ):
127
+ """Should return empty list when user has no agents."""
128
+ response = await client.get("/api/agents/", headers=auth_headers)
129
+
130
+ assert response.status_code == 200
131
+ data = response.json()
132
+ assert data == []
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_list_agents_with_agents(
136
+ self, client: AsyncClient, auth_headers: dict, agent_factory
137
+ ):
138
+ """Should return list of user's agents with summary metrics."""
139
+ # Create multiple agents
140
+ await agent_factory.create_agent(name="Agent 1", state="online")
141
+ await agent_factory.create_agent(name="Agent 2", state="offline")
142
+ await agent_factory.create_agent(name="Agent 3", state="idle")
143
+
144
+ response = await client.get("/api/agents/", headers=auth_headers)
145
+
146
+ assert response.status_code == 200
147
+ data = response.json()
148
+ assert len(data) == 3
149
+
150
+ # Verify all expected fields are present
151
+ for agent in data:
152
+ assert "id" in agent
153
+ assert "name" in agent
154
+ assert "state" in agent
155
+ assert "model_preference" in agent
156
+ assert "agent_type" in agent
157
+ assert "created_at" in agent
158
+ # Summary metrics should be present (may be 0)
159
+ assert "requests_24h" in agent
160
+ assert "errors_24h" in agent
161
+ assert "tokens_24h" in agent
162
+ assert "error_rate" in agent
163
+
164
+ @pytest.mark.asyncio
165
+ async def test_list_agents_excludes_deleted(
166
+ self, client: AsyncClient, auth_headers: dict, agent_factory, db_session
167
+ ):
168
+ """Should not return soft-deleted agents."""
169
+ from datetime import datetime, UTC
170
+
171
+ agent = await agent_factory.create_agent(name="To Delete")
172
+ agent.deleted_at = datetime.now(UTC)
173
+ await db_session.commit()
174
+
175
+ response = await client.get("/api/agents/", headers=auth_headers)
176
+
177
+ assert response.status_code == 200
178
+ data = response.json()
179
+ assert len(data) == 0
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_list_agents_isolation(
183
+ self, client: AsyncClient, auth_headers: dict, db_session, agent_factory
184
+ ):
185
+ """Should only return agents belonging to the authenticated user."""
186
+ from argon2 import PasswordHasher
187
+ from backend.models.user import User
188
+
189
+ # Create another user with their own agent
190
+ ph = PasswordHasher()
191
+ other_user = User(
192
+ id=str(uuid.uuid4()),
193
+ email="other@example.com",
194
+ hashed_password=ph.hash("password"),
195
+ email_verified=True,
196
+ )
197
+ db_session.add(other_user)
198
+ await db_session.commit()
199
+
200
+ other_agent = Agent(
201
+ id=str(uuid.uuid4()),
202
+ user_id=other_user.id,
203
+ name="Other User Agent",
204
+ state="online",
205
+ )
206
+ db_session.add(other_agent)
207
+ await db_session.commit()
208
+
209
+ # Create agent for test user
210
+ await agent_factory.create_agent(name="My Agent")
211
+
212
+ response = await client.get("/api/agents/", headers=auth_headers)
213
+
214
+ assert response.status_code == 200
215
+ data = response.json()
216
+ assert len(data) == 1
217
+ assert data[0]["name"] == "My Agent"
218
+
219
+
220
+ class TestGetAgent:
221
+ """Tests for GET /api/agents/{agent_id}"""
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_get_agent_success(
225
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
226
+ ):
227
+ """Should return agent details for valid ID."""
228
+ response = await client.get(
229
+ f"/api/agents/{test_agent.id}", headers=auth_headers
230
+ )
231
+
232
+ assert response.status_code == 200
233
+ data = response.json()
234
+ assert data["id"] == test_agent.id
235
+ assert data["name"] == test_agent.name
236
+ assert data["description"] == test_agent.description
237
+ assert data["state"] == test_agent.state
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_get_agent_not_found(
241
+ self, client: AsyncClient, auth_headers: dict
242
+ ):
243
+ """Should return 404 for non-existent agent ID."""
244
+ fake_id = str(uuid.uuid4())
245
+
246
+ response = await client.get(
247
+ f"/api/agents/{fake_id}", headers=auth_headers
248
+ )
249
+
250
+ assert response.status_code == 404
251
+ assert response.json()["detail"] == "Agent not found"
252
+
253
+ @pytest.mark.asyncio
254
+ async def test_get_agent_wrong_user(
255
+ self, client: AsyncClient, db_session, test_user
256
+ ):
257
+ """Should return 404 when accessing another user's agent."""
258
+ from argon2 import PasswordHasher
259
+ from backend.core.security import create_access_token
260
+ from backend.models.user import User
261
+
262
+ # Create another user
263
+ ph = PasswordHasher()
264
+ other_user = User(
265
+ id=str(uuid.uuid4()),
266
+ email="other@example.com",
267
+ hashed_password=ph.hash("password"),
268
+ email_verified=True,
269
+ )
270
+ db_session.add(other_user)
271
+
272
+ # Create agent for other user
273
+ other_agent = Agent(
274
+ id=str(uuid.uuid4()),
275
+ user_id=other_user.id,
276
+ name="Other Agent",
277
+ state="online",
278
+ )
279
+ db_session.add(other_agent)
280
+ await db_session.commit()
281
+
282
+ # Try to access with test_user's token
283
+ token = create_access_token(test_user.id)
284
+ headers = {"Authorization": f"Bearer {token}"}
285
+
286
+ response = await client.get(
287
+ f"/api/agents/{other_agent.id}", headers=headers
288
+ )
289
+
290
+ assert response.status_code == 404
291
+
292
+ @pytest.mark.asyncio
293
+ async def test_get_deleted_agent_returns_404(
294
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
295
+ ):
296
+ """Should return 404 for soft-deleted agent."""
297
+ from datetime import datetime, UTC
298
+
299
+ test_agent.deleted_at = datetime.now(UTC)
300
+ await db_session.commit()
301
+
302
+ response = await client.get(
303
+ f"/api/agents/{test_agent.id}", headers=auth_headers
304
+ )
305
+
306
+ assert response.status_code == 404
307
+
308
+
309
+ class TestUpdateAgent:
310
+ """Tests for PATCH /api/agents/{agent_id}"""
311
+
312
+ @pytest.mark.asyncio
313
+ async def test_update_agent_name(
314
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
315
+ ):
316
+ """Should update agent name."""
317
+ payload = {"name": "Updated Agent Name"}
318
+
319
+ response = await client.patch(
320
+ f"/api/agents/{test_agent.id}", json=payload, headers=auth_headers
321
+ )
322
+
323
+ assert response.status_code == 200
324
+ data = response.json()
325
+ assert data["name"] == "Updated Agent Name"
326
+
327
+ @pytest.mark.asyncio
328
+ async def test_update_agent_multiple_fields(
329
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
330
+ ):
331
+ """Should update multiple agent fields at once."""
332
+ payload = {
333
+ "name": "New Name",
334
+ "description": "New description",
335
+ "model_preference": "nyx-lite",
336
+ "system_prompt": "New system prompt",
337
+ }
338
+
339
+ response = await client.patch(
340
+ f"/api/agents/{test_agent.id}", json=payload, headers=auth_headers
341
+ )
342
+
343
+ assert response.status_code == 200
344
+ data = response.json()
345
+ assert data["name"] == "New Name"
346
+ assert data["description"] == "New description"
347
+ assert data["model_preference"] == "nyx-lite"
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_update_agent_partial(
351
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
352
+ ):
353
+ """Should allow partial updates without affecting other fields."""
354
+ original_name = test_agent.name
355
+ payload = {"description": "Only updating description"}
356
+
357
+ response = await client.patch(
358
+ f"/api/agents/{test_agent.id}", json=payload, headers=auth_headers
359
+ )
360
+
361
+ assert response.status_code == 200
362
+ data = response.json()
363
+ assert data["name"] == original_name
364
+ assert data["description"] == "Only updating description"
365
+
366
+ @pytest.mark.asyncio
367
+ async def test_update_agent_not_found(
368
+ self, client: AsyncClient, auth_headers: dict
369
+ ):
370
+ """Should return 404 for non-existent agent."""
371
+ fake_id = str(uuid.uuid4())
372
+ payload = {"name": "New Name"}
373
+
374
+ response = await client.patch(
375
+ f"/api/agents/{fake_id}", json=payload, headers=auth_headers
376
+ )
377
+
378
+ assert response.status_code == 404
379
+
380
+ @pytest.mark.asyncio
381
+ async def test_update_agent_empty_name_rejected(
382
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
383
+ ):
384
+ """Should reject update with empty name."""
385
+ payload = {"name": ""}
386
+
387
+ response = await client.patch(
388
+ f"/api/agents/{test_agent.id}", json=payload, headers=auth_headers
389
+ )
390
+
391
+ assert response.status_code == 422
392
+
393
+
394
+ class TestDeleteAgent:
395
+ """Tests for DELETE /api/agents/{agent_id}"""
396
+
397
+ @pytest.mark.asyncio
398
+ async def test_delete_agent_success(
399
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
400
+ ):
401
+ """Should soft delete an agent."""
402
+ response = await client.delete(
403
+ f"/api/agents/{test_agent.id}", headers=auth_headers
404
+ )
405
+
406
+ assert response.status_code == 200
407
+ assert response.json()["deleted"] is True
408
+
409
+ # Verify soft delete (agent still exists but has deleted_at set)
410
+ await db_session.refresh(test_agent)
411
+ assert test_agent.deleted_at is not None
412
+
413
+ @pytest.mark.asyncio
414
+ async def test_delete_agent_not_found(
415
+ self, client: AsyncClient, auth_headers: dict
416
+ ):
417
+ """Should return 404 for non-existent agent."""
418
+ fake_id = str(uuid.uuid4())
419
+
420
+ response = await client.delete(
421
+ f"/api/agents/{fake_id}", headers=auth_headers
422
+ )
423
+
424
+ assert response.status_code == 404
425
+
426
+ @pytest.mark.asyncio
427
+ async def test_delete_agent_idempotent(
428
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
429
+ ):
430
+ """Should return 404 when trying to delete already deleted agent."""
431
+ # First delete
432
+ response1 = await client.delete(
433
+ f"/api/agents/{test_agent.id}", headers=auth_headers
434
+ )
435
+ assert response1.status_code == 200
436
+
437
+ # Second delete should return 404 (agent is now "deleted")
438
+ response2 = await client.delete(
439
+ f"/api/agents/{test_agent.id}", headers=auth_headers
440
+ )
441
+ assert response2.status_code == 404
442
+
443
+ @pytest.mark.asyncio
444
+ async def test_deleted_agent_not_in_list(
445
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
446
+ ):
447
+ """Should not include deleted agent in list response."""
448
+ # Delete the agent
449
+ await client.delete(f"/api/agents/{test_agent.id}", headers=auth_headers)
450
+
451
+ # List should be empty
452
+ response = await client.get("/api/agents/", headers=auth_headers)
453
+
454
+ assert response.status_code == 200
455
+ assert len(response.json()) == 0