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,587 @@
1
+ """
2
+ End-to-end tests for Agent Metrics functionality.
3
+
4
+ Tests cover:
5
+ - Get metrics for different ranges (1h, 24h, 7d)
6
+ - Metrics aggregation
7
+ - Telemetry batch submission
8
+ """
9
+
10
+ import pytest
11
+ import uuid
12
+ from datetime import datetime, timedelta, UTC
13
+ from httpx import AsyncClient
14
+ from sqlalchemy import select
15
+
16
+ from backend.models.agent import Agent, AgentMetrics1m
17
+
18
+
19
+ class TestGetMetrics:
20
+ """Tests for GET /api/agents/{agent_id}/metrics"""
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_get_metrics_empty(
24
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
25
+ ):
26
+ """Should return empty data for agent with no metrics."""
27
+ response = await client.get(
28
+ f"/api/agents/{test_agent.id}/metrics", headers=auth_headers
29
+ )
30
+
31
+ assert response.status_code == 200
32
+ data = response.json()
33
+ assert data["agent_id"] == test_agent.id
34
+ assert data["data"] == []
35
+ assert data["summary"]["total_requests"] == 0
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_get_metrics_with_data(
39
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
40
+ ):
41
+ """Should return metrics data points."""
42
+ # Create metrics for the agent
43
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
44
+ await agent_factory.create_metrics(
45
+ test_agent.id,
46
+ bucket_time=now - timedelta(minutes=5),
47
+ request_count=10,
48
+ error_count=1,
49
+ input_tokens=1000,
50
+ output_tokens=500,
51
+ total_latency_ms=5000,
52
+ )
53
+
54
+ response = await client.get(
55
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
56
+ )
57
+
58
+ assert response.status_code == 200
59
+ data = response.json()
60
+ assert len(data["data"]) == 1
61
+ assert data["data"][0]["request_count"] == 10
62
+ assert data["data"][0]["error_count"] == 1
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_get_metrics_range_1h(
66
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
67
+ ):
68
+ """Should return metrics for 1 hour range with 1m granularity."""
69
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
70
+
71
+ # Create metrics at different times
72
+ for i in range(6):
73
+ await agent_factory.create_metrics(
74
+ test_agent.id,
75
+ bucket_time=now - timedelta(minutes=i * 10),
76
+ request_count=5,
77
+ )
78
+
79
+ response = await client.get(
80
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
81
+ )
82
+
83
+ assert response.status_code == 200
84
+ data = response.json()
85
+ assert data["range"] == "1h"
86
+ assert data["granularity"] == "1m"
87
+ assert len(data["data"]) == 6
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_get_metrics_range_24h(
91
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
92
+ ):
93
+ """Should return metrics for 24 hour range."""
94
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
95
+
96
+ # Create metrics spanning 24 hours
97
+ for i in range(24):
98
+ await agent_factory.create_metrics(
99
+ test_agent.id,
100
+ bucket_time=now - timedelta(hours=i),
101
+ request_count=10,
102
+ input_tokens=1000,
103
+ )
104
+
105
+ response = await client.get(
106
+ f"/api/agents/{test_agent.id}/metrics?range=24h", headers=auth_headers
107
+ )
108
+
109
+ assert response.status_code == 200
110
+ data = response.json()
111
+ assert data["range"] == "24h"
112
+ assert len(data["data"]) == 24
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_get_metrics_range_7d(
116
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
117
+ ):
118
+ """Should return metrics for 7 day range."""
119
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
120
+
121
+ # Create metrics spanning 7 days
122
+ for i in range(7):
123
+ await agent_factory.create_metrics(
124
+ test_agent.id,
125
+ bucket_time=now - timedelta(days=i),
126
+ request_count=100,
127
+ )
128
+
129
+ response = await client.get(
130
+ f"/api/agents/{test_agent.id}/metrics?range=7d", headers=auth_headers
131
+ )
132
+
133
+ assert response.status_code == 200
134
+ data = response.json()
135
+ assert data["range"] == "7d"
136
+ assert len(data["data"]) == 7
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_get_metrics_agent_not_found(
140
+ self, client: AsyncClient, auth_headers: dict
141
+ ):
142
+ """Should return 404 for non-existent agent."""
143
+ fake_id = str(uuid.uuid4())
144
+
145
+ response = await client.get(
146
+ f"/api/agents/{fake_id}/metrics", headers=auth_headers
147
+ )
148
+
149
+ assert response.status_code == 404
150
+
151
+
152
+ class TestMetricsSummary:
153
+ """Tests for metrics summary calculation."""
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_metrics_summary_totals(
157
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
158
+ ):
159
+ """Should calculate correct totals in summary."""
160
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
161
+
162
+ # Create metrics with known values
163
+ await agent_factory.create_metrics(
164
+ test_agent.id,
165
+ bucket_time=now - timedelta(minutes=5),
166
+ request_count=100,
167
+ error_count=10,
168
+ input_tokens=5000,
169
+ output_tokens=2500,
170
+ total_latency_ms=50000,
171
+ )
172
+ await agent_factory.create_metrics(
173
+ test_agent.id,
174
+ bucket_time=now - timedelta(minutes=10),
175
+ request_count=50,
176
+ error_count=5,
177
+ input_tokens=2500,
178
+ output_tokens=1250,
179
+ total_latency_ms=25000,
180
+ )
181
+
182
+ response = await client.get(
183
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
184
+ )
185
+
186
+ data = response.json()
187
+ summary = data["summary"]
188
+
189
+ assert summary["total_requests"] == 150
190
+ assert summary["total_errors"] == 15
191
+ assert summary["total_tokens"] == 11250 # 5000 + 2500 + 2500 + 1250
192
+ assert summary["error_rate"] == 10.0 # 15/150 * 100
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_metrics_summary_avg_latency(
196
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
197
+ ):
198
+ """Should calculate average latency correctly."""
199
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
200
+
201
+ await agent_factory.create_metrics(
202
+ test_agent.id,
203
+ bucket_time=now - timedelta(minutes=5),
204
+ request_count=10,
205
+ total_latency_ms=1000,
206
+ )
207
+
208
+ response = await client.get(
209
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
210
+ )
211
+
212
+ data = response.json()
213
+ summary = data["summary"]
214
+
215
+ assert summary["avg_latency_ms"] == 100.0 # 1000ms / 10 requests
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_metrics_summary_zero_requests(
219
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
220
+ ):
221
+ """Should handle zero requests gracefully."""
222
+ response = await client.get(
223
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
224
+ )
225
+
226
+ data = response.json()
227
+ summary = data["summary"]
228
+
229
+ assert summary["total_requests"] == 0
230
+ assert summary["error_rate"] == 0.0
231
+ assert summary["avg_latency_ms"] is None
232
+
233
+
234
+ class TestMetricsDataPoints:
235
+ """Tests for individual metric data points."""
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_data_point_fields(
239
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
240
+ ):
241
+ """Should include all expected fields in data points."""
242
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
243
+
244
+ await agent_factory.create_metrics(
245
+ test_agent.id,
246
+ bucket_time=now - timedelta(minutes=5),
247
+ request_count=20,
248
+ error_count=2,
249
+ input_tokens=2000,
250
+ output_tokens=1000,
251
+ total_latency_ms=4000,
252
+ tool_calls=5,
253
+ )
254
+
255
+ response = await client.get(
256
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
257
+ )
258
+
259
+ data = response.json()
260
+ point = data["data"][0]
261
+
262
+ assert "timestamp" in point
263
+ assert point["request_count"] == 20
264
+ assert point["error_count"] == 2
265
+ assert point["input_tokens"] == 2000
266
+ assert point["output_tokens"] == 1000
267
+ assert point["avg_latency_ms"] == 200.0 # 4000 / 20
268
+ assert point["tool_calls"] == 5
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_data_points_ordered_by_time(
272
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
273
+ ):
274
+ """Should return data points ordered by timestamp."""
275
+ now = datetime.now(UTC).replace(second=0, microsecond=0)
276
+
277
+ # Create metrics in non-chronological order
278
+ await agent_factory.create_metrics(
279
+ test_agent.id, bucket_time=now - timedelta(minutes=10)
280
+ )
281
+ await agent_factory.create_metrics(
282
+ test_agent.id, bucket_time=now - timedelta(minutes=30)
283
+ )
284
+ await agent_factory.create_metrics(
285
+ test_agent.id, bucket_time=now - timedelta(minutes=5)
286
+ )
287
+
288
+ response = await client.get(
289
+ f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
290
+ )
291
+
292
+ data = response.json()
293
+ timestamps = [point["timestamp"] for point in data["data"]]
294
+
295
+ # Should be in ascending order
296
+ assert timestamps == sorted(timestamps)
297
+
298
+
299
+ class TestTelemetryBatch:
300
+ """Tests for POST /api/agents/telemetry/batch"""
301
+
302
+ @pytest.mark.asyncio
303
+ async def test_submit_telemetry_batch_success(
304
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent
305
+ ):
306
+ """Should accept valid telemetry batch."""
307
+ now = datetime.now(UTC)
308
+ payload = {
309
+ "agent_id": test_agent.id,
310
+ "events": [
311
+ {
312
+ "request_id": str(uuid.uuid4()),
313
+ "timestamp_start": (now - timedelta(seconds=30)).isoformat(),
314
+ "timestamp_end": (now - timedelta(seconds=29)).isoformat(),
315
+ "latency_ms": 1000,
316
+ "input_tokens": 100,
317
+ "output_tokens": 50,
318
+ "model": "nyx",
319
+ "status": "success",
320
+ },
321
+ {
322
+ "request_id": str(uuid.uuid4()),
323
+ "timestamp_start": (now - timedelta(seconds=20)).isoformat(),
324
+ "timestamp_end": (now - timedelta(seconds=18)).isoformat(),
325
+ "latency_ms": 2000,
326
+ "input_tokens": 200,
327
+ "output_tokens": 100,
328
+ "model": "nyx",
329
+ "status": "success",
330
+ },
331
+ ],
332
+ }
333
+
334
+ response = await client.post(
335
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
336
+ )
337
+
338
+ assert response.status_code == 200
339
+ data = response.json()
340
+ assert data["accepted"] == 2
341
+ assert data["rejected"] == 0
342
+ assert data["errors"] == []
343
+
344
+ @pytest.mark.asyncio
345
+ async def test_telemetry_batch_records_metrics(
346
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
347
+ ):
348
+ """Should record telemetry events as metrics."""
349
+ now = datetime.now(UTC)
350
+ payload = {
351
+ "agent_id": test_agent.id,
352
+ "events": [
353
+ {
354
+ "request_id": str(uuid.uuid4()),
355
+ "timestamp_start": now.isoformat(),
356
+ "timestamp_end": (now + timedelta(seconds=1)).isoformat(),
357
+ "latency_ms": 500,
358
+ "input_tokens": 100,
359
+ "output_tokens": 50,
360
+ "model": "nyx",
361
+ "status": "success",
362
+ },
363
+ ],
364
+ }
365
+
366
+ await client.post(
367
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
368
+ )
369
+
370
+ # Check metrics recorded
371
+ stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
372
+ result = await db_session.execute(stmt)
373
+ metrics = result.scalar_one_or_none()
374
+
375
+ assert metrics is not None
376
+ assert metrics.request_count == 1
377
+ assert metrics.input_tokens == 100
378
+ assert metrics.output_tokens == 50
379
+
380
+ @pytest.mark.asyncio
381
+ async def test_telemetry_batch_error_events(
382
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
383
+ ):
384
+ """Should count error events correctly."""
385
+ now = datetime.now(UTC)
386
+ payload = {
387
+ "agent_id": test_agent.id,
388
+ "events": [
389
+ {
390
+ "request_id": str(uuid.uuid4()),
391
+ "timestamp_start": now.isoformat(),
392
+ "timestamp_end": (now + timedelta(seconds=1)).isoformat(),
393
+ "latency_ms": 500,
394
+ "input_tokens": 100,
395
+ "output_tokens": 0,
396
+ "model": "nyx",
397
+ "status": "error",
398
+ "error_type": "rate_limit",
399
+ },
400
+ ],
401
+ }
402
+
403
+ await client.post(
404
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
405
+ )
406
+
407
+ # Check error count
408
+ stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
409
+ result = await db_session.execute(stmt)
410
+ metrics = result.scalar_one_or_none()
411
+
412
+ assert metrics.error_count == 1
413
+
414
+ @pytest.mark.asyncio
415
+ async def test_telemetry_batch_timeout_events(
416
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
417
+ ):
418
+ """Should count timeout events correctly."""
419
+ now = datetime.now(UTC)
420
+ payload = {
421
+ "agent_id": test_agent.id,
422
+ "events": [
423
+ {
424
+ "request_id": str(uuid.uuid4()),
425
+ "timestamp_start": now.isoformat(),
426
+ "timestamp_end": (now + timedelta(seconds=30)).isoformat(),
427
+ "latency_ms": 30000,
428
+ "input_tokens": 100,
429
+ "output_tokens": 0,
430
+ "model": "nyx",
431
+ "status": "timeout",
432
+ },
433
+ ],
434
+ }
435
+
436
+ await client.post(
437
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
438
+ )
439
+
440
+ stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
441
+ result = await db_session.execute(stmt)
442
+ metrics = result.scalar_one_or_none()
443
+
444
+ assert metrics.timeout_count == 1
445
+ assert metrics.error_count == 1 # timeout also counts as error
446
+
447
+ @pytest.mark.asyncio
448
+ async def test_telemetry_batch_with_tool_calls(
449
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
450
+ ):
451
+ """Should record tool call counts."""
452
+ now = datetime.now(UTC)
453
+ payload = {
454
+ "agent_id": test_agent.id,
455
+ "events": [
456
+ {
457
+ "request_id": str(uuid.uuid4()),
458
+ "timestamp_start": now.isoformat(),
459
+ "timestamp_end": (now + timedelta(seconds=2)).isoformat(),
460
+ "latency_ms": 2000,
461
+ "input_tokens": 500,
462
+ "output_tokens": 200,
463
+ "model": "nyx",
464
+ "status": "success",
465
+ "tool_calls": [
466
+ {"tool": "web_search", "duration_ms": 500},
467
+ {"tool": "file_read", "duration_ms": 100},
468
+ {"tool": "web_search", "duration_ms": 300},
469
+ ],
470
+ },
471
+ ],
472
+ }
473
+
474
+ await client.post(
475
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
476
+ )
477
+
478
+ stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
479
+ result = await db_session.execute(stmt)
480
+ metrics = result.scalar_one_or_none()
481
+
482
+ assert metrics.tool_calls == 3
483
+
484
+ @pytest.mark.asyncio
485
+ async def test_telemetry_batch_agent_not_found(
486
+ self, client: AsyncClient, api_key_headers: dict
487
+ ):
488
+ """Should return 404 for non-existent agent."""
489
+ fake_id = str(uuid.uuid4())
490
+ payload = {
491
+ "agent_id": fake_id,
492
+ "events": [],
493
+ }
494
+
495
+ response = await client.post(
496
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
497
+ )
498
+
499
+ assert response.status_code == 404
500
+
501
+ @pytest.mark.asyncio
502
+ async def test_telemetry_batch_empty_events(
503
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent
504
+ ):
505
+ """Should handle empty events list."""
506
+ payload = {
507
+ "agent_id": test_agent.id,
508
+ "events": [],
509
+ }
510
+
511
+ response = await client.post(
512
+ "/api/agents/telemetry/batch", json=payload, headers=api_key_headers
513
+ )
514
+
515
+ assert response.status_code == 200
516
+ data = response.json()
517
+ assert data["accepted"] == 0
518
+ assert data["rejected"] == 0
519
+
520
+
521
+ class TestMetricsGranularity:
522
+ """Tests for metrics granularity auto-selection."""
523
+
524
+ @pytest.mark.asyncio
525
+ async def test_auto_granularity_1h_returns_1m(
526
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
527
+ ):
528
+ """Should use 1m granularity for 1h range."""
529
+ response = await client.get(
530
+ f"/api/agents/{test_agent.id}/metrics?range=1h&granularity=auto",
531
+ headers=auth_headers,
532
+ )
533
+
534
+ assert response.status_code == 200
535
+ assert response.json()["granularity"] == "1m"
536
+
537
+ @pytest.mark.asyncio
538
+ async def test_auto_granularity_6h_returns_1m(
539
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
540
+ ):
541
+ """Should use 1m granularity for 6h range."""
542
+ response = await client.get(
543
+ f"/api/agents/{test_agent.id}/metrics?range=6h&granularity=auto",
544
+ headers=auth_headers,
545
+ )
546
+
547
+ assert response.status_code == 200
548
+ assert response.json()["granularity"] == "1m"
549
+
550
+ @pytest.mark.asyncio
551
+ async def test_auto_granularity_24h_returns_1h(
552
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
553
+ ):
554
+ """Should use 1h granularity for 24h range."""
555
+ response = await client.get(
556
+ f"/api/agents/{test_agent.id}/metrics?range=24h&granularity=auto",
557
+ headers=auth_headers,
558
+ )
559
+
560
+ assert response.status_code == 200
561
+ assert response.json()["granularity"] == "1h"
562
+
563
+ @pytest.mark.asyncio
564
+ async def test_auto_granularity_30d_returns_1d(
565
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
566
+ ):
567
+ """Should use 1d granularity for 30d range."""
568
+ response = await client.get(
569
+ f"/api/agents/{test_agent.id}/metrics?range=30d&granularity=auto",
570
+ headers=auth_headers,
571
+ )
572
+
573
+ assert response.status_code == 200
574
+ assert response.json()["granularity"] == "1d"
575
+
576
+ @pytest.mark.asyncio
577
+ async def test_explicit_granularity_override(
578
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
579
+ ):
580
+ """Should respect explicit granularity setting."""
581
+ response = await client.get(
582
+ f"/api/agents/{test_agent.id}/metrics?range=24h&granularity=1m",
583
+ headers=auth_headers,
584
+ )
585
+
586
+ assert response.status_code == 200
587
+ assert response.json()["granularity"] == "1m"