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,802 @@
1
+ """
2
+ End-to-end tests for Agent Alerts functionality.
3
+
4
+ Tests cover:
5
+ - Create alert config
6
+ - Update alert config
7
+ - Delete alert config
8
+ - Get alert history
9
+ """
10
+
11
+ import pytest
12
+ import uuid
13
+ from datetime import datetime, timedelta, UTC
14
+ from httpx import AsyncClient
15
+ from sqlalchemy import select
16
+
17
+ from backend.models.agent import Agent, AgentAlertConfig, AgentAlertHistory
18
+
19
+
20
+ class TestCreateAlertConfig:
21
+ """Tests for POST /api/agents/{agent_id}/alerts"""
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_create_alert_config_success(
25
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
26
+ ):
27
+ """Should create an alert configuration."""
28
+ payload = {
29
+ "name": "High Error Rate Alert",
30
+ "alert_type": "error_rate",
31
+ "metric": "error_rate",
32
+ "condition": "gt",
33
+ "threshold": 10.0,
34
+ "window_seconds": 300,
35
+ "cooldown_seconds": 3600,
36
+ "severity": "warning",
37
+ "channels": ["email"],
38
+ }
39
+
40
+ response = await client.post(
41
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
42
+ )
43
+
44
+ assert response.status_code == 200
45
+ data = response.json()
46
+ assert data["name"] == "High Error Rate Alert"
47
+ assert data["alert_type"] == "error_rate"
48
+ assert data["metric"] == "error_rate"
49
+ assert data["condition"] == "gt"
50
+ assert data["threshold"] == 10.0
51
+ assert data["window_seconds"] == 300
52
+ assert data["cooldown_seconds"] == 3600
53
+ assert data["severity"] == "warning"
54
+ assert data["channels"] == ["email"]
55
+ assert data["is_enabled"] is True
56
+ assert "id" in data
57
+ assert "created_at" in data
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_create_offline_alert(
61
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
62
+ ):
63
+ """Should create an offline detection alert."""
64
+ payload = {
65
+ "name": "Agent Offline Alert",
66
+ "alert_type": "offline",
67
+ "metric": "heartbeat",
68
+ "condition": "gt",
69
+ "threshold": 120, # seconds without heartbeat
70
+ "window_seconds": 60,
71
+ "severity": "critical",
72
+ "channels": ["email", "slack"],
73
+ }
74
+
75
+ response = await client.post(
76
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
77
+ )
78
+
79
+ assert response.status_code == 200
80
+ data = response.json()
81
+ assert data["alert_type"] == "offline"
82
+ assert data["severity"] == "critical"
83
+ assert "slack" in data["channels"]
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_create_latency_alert(
87
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
88
+ ):
89
+ """Should create a latency threshold alert."""
90
+ payload = {
91
+ "name": "High Latency Alert",
92
+ "alert_type": "latency",
93
+ "metric": "avg_latency_ms",
94
+ "condition": "gte",
95
+ "threshold": 5000, # 5 seconds
96
+ "window_seconds": 600, # 10 minutes
97
+ "severity": "warning",
98
+ }
99
+
100
+ response = await client.post(
101
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
102
+ )
103
+
104
+ assert response.status_code == 200
105
+ data = response.json()
106
+ assert data["alert_type"] == "latency"
107
+ assert data["threshold"] == 5000
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_create_token_budget_alert(
111
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
112
+ ):
113
+ """Should create a token budget alert."""
114
+ payload = {
115
+ "name": "Daily Token Limit Alert",
116
+ "alert_type": "token_budget",
117
+ "metric": "daily_tokens",
118
+ "condition": "gt",
119
+ "threshold": 100000,
120
+ "window_seconds": 86400, # 24 hours
121
+ "severity": "info",
122
+ }
123
+
124
+ response = await client.post(
125
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
126
+ )
127
+
128
+ assert response.status_code == 200
129
+ data = response.json()
130
+ assert data["alert_type"] == "token_budget"
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_create_alert_validation_errors(
134
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
135
+ ):
136
+ """Should reject invalid alert configuration."""
137
+ # Invalid alert_type
138
+ payload = {
139
+ "name": "Test Alert",
140
+ "alert_type": "invalid_type",
141
+ "metric": "error_rate",
142
+ "condition": "gt",
143
+ "threshold": 10.0,
144
+ }
145
+
146
+ response = await client.post(
147
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
148
+ )
149
+
150
+ assert response.status_code == 422
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_create_alert_invalid_condition(
154
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
155
+ ):
156
+ """Should reject invalid condition operator."""
157
+ payload = {
158
+ "name": "Test Alert",
159
+ "alert_type": "error_rate",
160
+ "metric": "error_rate",
161
+ "condition": "invalid",
162
+ "threshold": 10.0,
163
+ }
164
+
165
+ response = await client.post(
166
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
167
+ )
168
+
169
+ assert response.status_code == 422
170
+
171
+ @pytest.mark.asyncio
172
+ async def test_create_alert_agent_not_found(
173
+ self, client: AsyncClient, auth_headers: dict
174
+ ):
175
+ """Should return 404 for non-existent agent."""
176
+ fake_id = str(uuid.uuid4())
177
+ payload = {
178
+ "name": "Test Alert",
179
+ "alert_type": "error_rate",
180
+ "metric": "error_rate",
181
+ "condition": "gt",
182
+ "threshold": 10.0,
183
+ }
184
+
185
+ response = await client.post(
186
+ f"/api/agents/{fake_id}/alerts", json=payload, headers=auth_headers
187
+ )
188
+
189
+ assert response.status_code == 404
190
+
191
+
192
+ class TestGetAlertConfigs:
193
+ """Tests for GET /api/agents/{agent_id}/alerts"""
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_get_alerts_empty(
197
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
198
+ ):
199
+ """Should return empty list when no alerts configured."""
200
+ response = await client.get(
201
+ f"/api/agents/{test_agent.id}/alerts", headers=auth_headers
202
+ )
203
+
204
+ assert response.status_code == 200
205
+ data = response.json()
206
+ assert data == []
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_get_alerts_with_configs(
210
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
211
+ ):
212
+ """Should return list of alert configurations."""
213
+ await agent_factory.create_alert_config(
214
+ test_agent.id, name="Error Rate Alert", alert_type="error_rate"
215
+ )
216
+ await agent_factory.create_alert_config(
217
+ test_agent.id, name="Latency Alert", alert_type="latency"
218
+ )
219
+
220
+ response = await client.get(
221
+ f"/api/agents/{test_agent.id}/alerts", headers=auth_headers
222
+ )
223
+
224
+ assert response.status_code == 200
225
+ data = response.json()
226
+ assert len(data) == 2
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_get_alerts_agent_not_found(
230
+ self, client: AsyncClient, auth_headers: dict
231
+ ):
232
+ """Should return 404 for non-existent agent."""
233
+ fake_id = str(uuid.uuid4())
234
+
235
+ response = await client.get(
236
+ f"/api/agents/{fake_id}/alerts", headers=auth_headers
237
+ )
238
+
239
+ assert response.status_code == 404
240
+
241
+
242
+ class TestUpdateAlertConfig:
243
+ """Tests for PATCH /api/agents/{agent_id}/alerts/{config_id}"""
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_update_alert_threshold(
247
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
248
+ ):
249
+ """Should update alert threshold."""
250
+ config = await agent_factory.create_alert_config(
251
+ test_agent.id, threshold=10.0
252
+ )
253
+
254
+ payload = {"threshold": 20.0}
255
+
256
+ response = await client.patch(
257
+ f"/api/agents/{test_agent.id}/alerts/{config.id}",
258
+ json=payload,
259
+ headers=auth_headers,
260
+ )
261
+
262
+ assert response.status_code == 200
263
+ data = response.json()
264
+ assert data["threshold"] == 20.0
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_update_alert_name(
268
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
269
+ ):
270
+ """Should update alert name."""
271
+ config = await agent_factory.create_alert_config(
272
+ test_agent.id, name="Original Name"
273
+ )
274
+
275
+ payload = {"name": "Updated Name"}
276
+
277
+ response = await client.patch(
278
+ f"/api/agents/{test_agent.id}/alerts/{config.id}",
279
+ json=payload,
280
+ headers=auth_headers,
281
+ )
282
+
283
+ assert response.status_code == 200
284
+ assert response.json()["name"] == "Updated Name"
285
+
286
+ @pytest.mark.asyncio
287
+ async def test_update_alert_disable(
288
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
289
+ ):
290
+ """Should disable an alert."""
291
+ config = await agent_factory.create_alert_config(
292
+ test_agent.id, is_enabled=True
293
+ )
294
+
295
+ payload = {"is_enabled": False}
296
+
297
+ response = await client.patch(
298
+ f"/api/agents/{test_agent.id}/alerts/{config.id}",
299
+ json=payload,
300
+ headers=auth_headers,
301
+ )
302
+
303
+ assert response.status_code == 200
304
+ assert response.json()["is_enabled"] is False
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_update_alert_channels(
308
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
309
+ ):
310
+ """Should update alert notification channels."""
311
+ config = await agent_factory.create_alert_config(
312
+ test_agent.id, channels=["email"]
313
+ )
314
+
315
+ payload = {"channels": ["email", "slack", "webhook"]}
316
+
317
+ response = await client.patch(
318
+ f"/api/agents/{test_agent.id}/alerts/{config.id}",
319
+ json=payload,
320
+ headers=auth_headers,
321
+ )
322
+
323
+ assert response.status_code == 200
324
+ assert set(response.json()["channels"]) == {"email", "slack", "webhook"}
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_update_alert_severity(
328
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
329
+ ):
330
+ """Should update alert severity."""
331
+ config = await agent_factory.create_alert_config(
332
+ test_agent.id, severity="warning"
333
+ )
334
+
335
+ payload = {"severity": "critical"}
336
+
337
+ response = await client.patch(
338
+ f"/api/agents/{test_agent.id}/alerts/{config.id}",
339
+ json=payload,
340
+ headers=auth_headers,
341
+ )
342
+
343
+ assert response.status_code == 200
344
+ assert response.json()["severity"] == "critical"
345
+
346
+ @pytest.mark.asyncio
347
+ async def test_update_alert_multiple_fields(
348
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
349
+ ):
350
+ """Should update multiple fields at once."""
351
+ config = await agent_factory.create_alert_config(test_agent.id)
352
+
353
+ payload = {
354
+ "name": "Updated Alert",
355
+ "threshold": 50.0,
356
+ "window_seconds": 600,
357
+ "severity": "info",
358
+ }
359
+
360
+ response = await client.patch(
361
+ f"/api/agents/{test_agent.id}/alerts/{config.id}",
362
+ json=payload,
363
+ headers=auth_headers,
364
+ )
365
+
366
+ assert response.status_code == 200
367
+ data = response.json()
368
+ assert data["name"] == "Updated Alert"
369
+ assert data["threshold"] == 50.0
370
+ assert data["window_seconds"] == 600
371
+ assert data["severity"] == "info"
372
+
373
+ @pytest.mark.asyncio
374
+ async def test_update_alert_not_found(
375
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
376
+ ):
377
+ """Should return 404 for non-existent alert config."""
378
+ fake_id = str(uuid.uuid4())
379
+ payload = {"threshold": 20.0}
380
+
381
+ response = await client.patch(
382
+ f"/api/agents/{test_agent.id}/alerts/{fake_id}",
383
+ json=payload,
384
+ headers=auth_headers,
385
+ )
386
+
387
+ assert response.status_code == 404
388
+
389
+
390
+ class TestDeleteAlertConfig:
391
+ """Tests for DELETE /api/agents/{agent_id}/alerts/{config_id}"""
392
+
393
+ @pytest.mark.asyncio
394
+ async def test_delete_alert_success(
395
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory, db_session
396
+ ):
397
+ """Should delete an alert configuration."""
398
+ config = await agent_factory.create_alert_config(test_agent.id)
399
+ config_id = config.id
400
+
401
+ response = await client.delete(
402
+ f"/api/agents/{test_agent.id}/alerts/{config_id}",
403
+ headers=auth_headers,
404
+ )
405
+
406
+ assert response.status_code == 200
407
+ assert response.json()["deleted"] is True
408
+
409
+ # Verify deleted
410
+ stmt = select(AgentAlertConfig).where(AgentAlertConfig.id == config_id)
411
+ result = await db_session.execute(stmt)
412
+ assert result.scalar_one_or_none() is None
413
+
414
+ @pytest.mark.asyncio
415
+ async def test_delete_alert_not_found(
416
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
417
+ ):
418
+ """Should return 404 for non-existent alert config."""
419
+ fake_id = str(uuid.uuid4())
420
+
421
+ response = await client.delete(
422
+ f"/api/agents/{test_agent.id}/alerts/{fake_id}",
423
+ headers=auth_headers,
424
+ )
425
+
426
+ assert response.status_code == 404
427
+
428
+ @pytest.mark.asyncio
429
+ async def test_delete_alert_wrong_agent(
430
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory, db_session, test_user
431
+ ):
432
+ """Should return 404 when config belongs to different agent."""
433
+ # Create another agent
434
+ other_agent = Agent(
435
+ id=str(uuid.uuid4()),
436
+ user_id=test_user.id,
437
+ name="Other Agent",
438
+ )
439
+ db_session.add(other_agent)
440
+ await db_session.commit()
441
+
442
+ # Create config for other agent
443
+ other_config = AgentAlertConfig(
444
+ id=str(uuid.uuid4()),
445
+ agent_id=other_agent.id,
446
+ user_id=test_user.id,
447
+ name="Other Alert",
448
+ alert_type="error_rate",
449
+ metric="error_rate",
450
+ condition="gt",
451
+ threshold=10.0,
452
+ )
453
+ db_session.add(other_config)
454
+ await db_session.commit()
455
+
456
+ # Try to delete using test_agent's endpoint
457
+ response = await client.delete(
458
+ f"/api/agents/{test_agent.id}/alerts/{other_config.id}",
459
+ headers=auth_headers,
460
+ )
461
+
462
+ assert response.status_code == 404
463
+
464
+
465
+ class TestAlertHistory:
466
+ """Tests for GET /api/agents/{agent_id}/alerts/history"""
467
+
468
+ @pytest.mark.asyncio
469
+ async def test_get_alert_history_empty(
470
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
471
+ ):
472
+ """Should return empty list when no alerts have triggered."""
473
+ response = await client.get(
474
+ f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
475
+ )
476
+
477
+ assert response.status_code == 200
478
+ data = response.json()
479
+ assert data == []
480
+
481
+ @pytest.mark.asyncio
482
+ async def test_get_alert_history_with_entries(
483
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
484
+ ):
485
+ """Should return alert history entries."""
486
+ config = await agent_factory.create_alert_config(test_agent.id)
487
+
488
+ # Create history entries
489
+ await agent_factory.create_alert_history(
490
+ config.id,
491
+ test_agent.id,
492
+ alert_type="error_rate",
493
+ severity="warning",
494
+ message="Error rate exceeded 10%",
495
+ )
496
+ await agent_factory.create_alert_history(
497
+ config.id,
498
+ test_agent.id,
499
+ alert_type="error_rate",
500
+ severity="warning",
501
+ status="resolved",
502
+ )
503
+
504
+ response = await client.get(
505
+ f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
506
+ )
507
+
508
+ assert response.status_code == 200
509
+ data = response.json()
510
+ assert len(data) == 2
511
+
512
+ @pytest.mark.asyncio
513
+ async def test_alert_history_fields(
514
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
515
+ ):
516
+ """Should return expected fields in history entry."""
517
+ config = await agent_factory.create_alert_config(test_agent.id)
518
+
519
+ await agent_factory.create_alert_history(
520
+ config.id,
521
+ test_agent.id,
522
+ alert_type="latency",
523
+ severity="critical",
524
+ status="triggered",
525
+ message="Latency exceeded 5000ms",
526
+ trigger_value=6500.0,
527
+ threshold_value=5000.0,
528
+ )
529
+
530
+ response = await client.get(
531
+ f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
532
+ )
533
+
534
+ data = response.json()
535
+ entry = data[0]
536
+
537
+ assert "id" in entry
538
+ assert entry["alert_type"] == "latency"
539
+ assert entry["severity"] == "critical"
540
+ assert entry["status"] == "triggered"
541
+ assert entry["message"] == "Latency exceeded 5000ms"
542
+ assert entry["trigger_value"] == 6500.0
543
+ assert entry["threshold_value"] == 5000.0
544
+ assert "created_at" in entry
545
+
546
+ @pytest.mark.asyncio
547
+ async def test_alert_history_ordered_desc(
548
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory, db_session
549
+ ):
550
+ """Should return history in descending time order."""
551
+ config = await agent_factory.create_alert_config(test_agent.id)
552
+ now = datetime.now(UTC)
553
+
554
+ # Create entries with different times
555
+ for i in range(3):
556
+ history = AgentAlertHistory(
557
+ id=str(uuid.uuid4()),
558
+ config_id=config.id,
559
+ agent_id=test_agent.id,
560
+ alert_type="error_rate",
561
+ severity="warning",
562
+ status="triggered",
563
+ )
564
+ db_session.add(history)
565
+ await db_session.commit()
566
+ history.created_at = now - timedelta(hours=i)
567
+ await db_session.commit()
568
+
569
+ response = await client.get(
570
+ f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
571
+ )
572
+
573
+ data = response.json()
574
+ timestamps = [entry["created_at"] for entry in data]
575
+
576
+ # Should be in descending order (newest first)
577
+ assert timestamps == sorted(timestamps, reverse=True)
578
+
579
+ @pytest.mark.asyncio
580
+ async def test_alert_history_limit(
581
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
582
+ ):
583
+ """Should respect limit parameter."""
584
+ config = await agent_factory.create_alert_config(test_agent.id)
585
+
586
+ # Create 20 history entries
587
+ for _ in range(20):
588
+ await agent_factory.create_alert_history(
589
+ config.id, test_agent.id
590
+ )
591
+
592
+ response = await client.get(
593
+ f"/api/agents/{test_agent.id}/alerts/history?limit=5",
594
+ headers=auth_headers,
595
+ )
596
+
597
+ assert response.status_code == 200
598
+ assert len(response.json()) == 5
599
+
600
+ @pytest.mark.asyncio
601
+ async def test_alert_history_agent_not_found(
602
+ self, client: AsyncClient, auth_headers: dict
603
+ ):
604
+ """Should return 404 for non-existent agent."""
605
+ fake_id = str(uuid.uuid4())
606
+
607
+ response = await client.get(
608
+ f"/api/agents/{fake_id}/alerts/history", headers=auth_headers
609
+ )
610
+
611
+ assert response.status_code == 404
612
+
613
+
614
+ class TestAlertConditions:
615
+ """Tests for different alert condition operators."""
616
+
617
+ @pytest.mark.asyncio
618
+ async def test_create_alert_condition_gt(
619
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
620
+ ):
621
+ """Should accept 'gt' (greater than) condition."""
622
+ payload = {
623
+ "name": "Test",
624
+ "alert_type": "error_rate",
625
+ "metric": "error_rate",
626
+ "condition": "gt",
627
+ "threshold": 10.0,
628
+ }
629
+
630
+ response = await client.post(
631
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
632
+ )
633
+
634
+ assert response.status_code == 200
635
+ assert response.json()["condition"] == "gt"
636
+
637
+ @pytest.mark.asyncio
638
+ async def test_create_alert_condition_lt(
639
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
640
+ ):
641
+ """Should accept 'lt' (less than) condition."""
642
+ payload = {
643
+ "name": "Test",
644
+ "alert_type": "error_rate",
645
+ "metric": "uptime_percent",
646
+ "condition": "lt",
647
+ "threshold": 99.0,
648
+ }
649
+
650
+ response = await client.post(
651
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
652
+ )
653
+
654
+ assert response.status_code == 200
655
+ assert response.json()["condition"] == "lt"
656
+
657
+ @pytest.mark.asyncio
658
+ async def test_create_alert_condition_gte(
659
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
660
+ ):
661
+ """Should accept 'gte' (greater than or equal) condition."""
662
+ payload = {
663
+ "name": "Test",
664
+ "alert_type": "latency",
665
+ "metric": "avg_latency_ms",
666
+ "condition": "gte",
667
+ "threshold": 1000.0,
668
+ }
669
+
670
+ response = await client.post(
671
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
672
+ )
673
+
674
+ assert response.status_code == 200
675
+ assert response.json()["condition"] == "gte"
676
+
677
+ @pytest.mark.asyncio
678
+ async def test_create_alert_condition_lte(
679
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
680
+ ):
681
+ """Should accept 'lte' (less than or equal) condition."""
682
+ payload = {
683
+ "name": "Test",
684
+ "alert_type": "error_rate",
685
+ "metric": "requests_per_minute",
686
+ "condition": "lte",
687
+ "threshold": 5.0,
688
+ }
689
+
690
+ response = await client.post(
691
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
692
+ )
693
+
694
+ assert response.status_code == 200
695
+ assert response.json()["condition"] == "lte"
696
+
697
+ @pytest.mark.asyncio
698
+ async def test_create_alert_condition_eq(
699
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
700
+ ):
701
+ """Should accept 'eq' (equal) condition."""
702
+ payload = {
703
+ "name": "Test",
704
+ "alert_type": "offline",
705
+ "metric": "state",
706
+ "condition": "eq",
707
+ "threshold": 0.0, # offline = 0
708
+ }
709
+
710
+ response = await client.post(
711
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
712
+ )
713
+
714
+ assert response.status_code == 200
715
+ assert response.json()["condition"] == "eq"
716
+
717
+
718
+ class TestAlertSeverityLevels:
719
+ """Tests for alert severity levels."""
720
+
721
+ @pytest.mark.asyncio
722
+ async def test_create_alert_severity_info(
723
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
724
+ ):
725
+ """Should accept 'info' severity."""
726
+ payload = {
727
+ "name": "Test",
728
+ "alert_type": "token_budget",
729
+ "metric": "tokens",
730
+ "condition": "gt",
731
+ "threshold": 50000,
732
+ "severity": "info",
733
+ }
734
+
735
+ response = await client.post(
736
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
737
+ )
738
+
739
+ assert response.status_code == 200
740
+ assert response.json()["severity"] == "info"
741
+
742
+ @pytest.mark.asyncio
743
+ async def test_create_alert_severity_warning(
744
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
745
+ ):
746
+ """Should accept 'warning' severity."""
747
+ payload = {
748
+ "name": "Test",
749
+ "alert_type": "error_rate",
750
+ "metric": "error_rate",
751
+ "condition": "gt",
752
+ "threshold": 10.0,
753
+ "severity": "warning",
754
+ }
755
+
756
+ response = await client.post(
757
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
758
+ )
759
+
760
+ assert response.status_code == 200
761
+ assert response.json()["severity"] == "warning"
762
+
763
+ @pytest.mark.asyncio
764
+ async def test_create_alert_severity_critical(
765
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
766
+ ):
767
+ """Should accept 'critical' severity."""
768
+ payload = {
769
+ "name": "Test",
770
+ "alert_type": "offline",
771
+ "metric": "heartbeat",
772
+ "condition": "gt",
773
+ "threshold": 300,
774
+ "severity": "critical",
775
+ }
776
+
777
+ response = await client.post(
778
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
779
+ )
780
+
781
+ assert response.status_code == 200
782
+ assert response.json()["severity"] == "critical"
783
+
784
+ @pytest.mark.asyncio
785
+ async def test_create_alert_invalid_severity(
786
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
787
+ ):
788
+ """Should reject invalid severity level."""
789
+ payload = {
790
+ "name": "Test",
791
+ "alert_type": "error_rate",
792
+ "metric": "error_rate",
793
+ "condition": "gt",
794
+ "threshold": 10.0,
795
+ "severity": "emergency", # invalid
796
+ }
797
+
798
+ response = await client.post(
799
+ f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
800
+ )
801
+
802
+ assert response.status_code == 422