openclaw-agent-dashboard 1.0.39 → 1.0.41

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 (58) hide show
  1. package/dashboard/api/agent_config_api.py +28 -7
  2. package/dashboard/api/agents.py +48 -10
  3. package/dashboard/api/agents_config.py +5 -1
  4. package/dashboard/api/chains.py +25 -5
  5. package/dashboard/api/collaboration.py +10 -9
  6. package/dashboard/api/debug_paths.py +5 -1
  7. package/dashboard/api/error_analysis.py +29 -11
  8. package/dashboard/api/errors.py +37 -11
  9. package/dashboard/api/fortify_routes.py +108 -0
  10. package/dashboard/api/input_safety.py +60 -0
  11. package/dashboard/api/performance.py +73 -53
  12. package/dashboard/api/subagents.py +95 -99
  13. package/dashboard/api/timeline.py +24 -3
  14. package/dashboard/api/version.py +2 -0
  15. package/dashboard/api/websocket.py +9 -7
  16. package/dashboard/core/__init__.py +1 -0
  17. package/dashboard/core/config_fortify.py +125 -0
  18. package/dashboard/core/error_handler.py +488 -0
  19. package/dashboard/core/fallback_manager.py +81 -0
  20. package/dashboard/core/logging_config.py +217 -0
  21. package/dashboard/core/safe_api_error.py +76 -0
  22. package/dashboard/core/schemas/__init__.py +16 -0
  23. package/dashboard/core/schemas/base.py +43 -0
  24. package/dashboard/core/schemas/session_schema.py +40 -0
  25. package/dashboard/core/schemas/subagent_schema.py +23 -0
  26. package/dashboard/data/agent_config_manager.py +6 -4
  27. package/dashboard/data/chain_reader.py +16 -12
  28. package/dashboard/data/error_analyzer.py +15 -11
  29. package/dashboard/data/session_reader.py +268 -46
  30. package/dashboard/data/subagent_reader.py +74 -49
  31. package/dashboard/data/timeline_reader.py +35 -49
  32. package/dashboard/main.py +24 -2
  33. package/dashboard/mechanism_reader.py +4 -5
  34. package/dashboard/mechanisms.py +2 -2
  35. package/dashboard/pytest.ini +3 -0
  36. package/dashboard/requirements.txt +5 -0
  37. package/dashboard/status/cache_fp_probe.py +40 -0
  38. package/dashboard/status/status_cache.py +199 -72
  39. package/dashboard/status/status_calculator.py +50 -30
  40. package/dashboard/tests/conftest.py +87 -0
  41. package/dashboard/tests/test_api_contracts.py +372 -0
  42. package/dashboard/tests/test_bench_fortify.py +176 -0
  43. package/dashboard/tests/test_fortify.py +952 -0
  44. package/dashboard/utils/__init__.py +1 -0
  45. package/dashboard/utils/data_repair.py +210 -0
  46. package/dashboard/watchers/file_watcher.py +380 -77
  47. package/frontend-dist/assets/{index-cYIOn3Wq.css → index-BIZ2xHfw.css} +1 -1
  48. package/frontend-dist/assets/{index-DyRXGevD.js → index-Cnr0b02R.js} +1 -1
  49. package/frontend-dist/index.html +2 -2
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +1 -1
  52. package/dashboard/agents.py +0 -74
  53. package/dashboard/collaboration.py +0 -407
  54. package/dashboard/errors.py +0 -63
  55. package/dashboard/performance.py +0 -474
  56. package/dashboard/session_reader.py +0 -240
  57. package/dashboard/status_calculator.py +0 -121
  58. package/dashboard/subagent_reader.py +0 -232
@@ -77,48 +77,68 @@ def calculate_agent_status(agent_id: str, use_cache: bool = True) -> AgentStatus
77
77
  cached = cache.get(agent_id)
78
78
  if cached and 'status' in cached:
79
79
  return cached['status']
80
-
81
- # 重新计算
82
- # 检查异常
83
- if has_recent_errors(agent_id, minutes=5):
84
- status = 'down'
85
- # 检查工作中:subagent run 未结束(与连线 activePath 同源)
86
- elif is_agent_working(agent_id):
87
- status = 'working'
88
- elif _main_agent_solo_processing(agent_id):
89
- status = 'working'
90
- else:
91
- # 默认空闲
92
- status = 'idle'
93
-
80
+
81
+ try:
82
+ # 重新计算
83
+ if has_recent_errors(agent_id, minutes=5):
84
+ status = 'down'
85
+ elif is_agent_working(agent_id):
86
+ status = 'working'
87
+ elif _main_agent_solo_processing(agent_id):
88
+ status = 'working'
89
+ else:
90
+ status = 'idle'
91
+ except OSError as e:
92
+ from core.error_handler import classify_exception, record_error
93
+ from core.fallback_manager import run_fallback
94
+
95
+ cat = classify_exception(e)
96
+ record_error(cat, str(e), f"status_calculator:calculate:{agent_id}", exc=e)
97
+ fb = run_fallback(cat, agent_id=agent_id)
98
+ if fb is not None:
99
+ return fb # type: ignore[return-value]
100
+ return 'idle'
101
+
94
102
  # 更新缓存(只缓存状态)
95
103
  if use_cache:
96
104
  cache = get_cache()
97
105
  cache.set(agent_id, {'status': status})
98
-
106
+
99
107
  return status
100
108
 
101
109
 
102
110
  def get_agents_with_status() -> list:
103
111
  """获取所有 Agent 及其状态"""
104
- agents = get_agents_list()
112
+ try:
113
+ agents = get_agents_list()
114
+ except OSError as e:
115
+ from core.error_handler import classify_exception, record_error
116
+
117
+ record_error(classify_exception(e), str(e), "get_agents_with_status:list", exc=e)
118
+ return []
119
+
105
120
  result = []
106
-
121
+
107
122
  for agent in agents:
108
123
  agent_id = agent.get('id')
109
- status = calculate_agent_status(agent_id)
110
-
111
- # 获取当前任务(仅工作中展示;空闲时不应残留已结束 run 的文案)
112
- current_task = get_current_task(agent_id)
113
- if status == 'idle':
124
+ try:
125
+ status = calculate_agent_status(agent_id)
126
+ current_task = get_current_task(agent_id)
127
+ if status == 'idle':
128
+ current_task = ''
129
+ last_active = get_last_active_time(agent_id)
130
+ last_error = get_last_error(agent_id) if status == 'down' else None
131
+ except OSError as e:
132
+ from core.error_handler import classify_exception, record_error
133
+ from core.fallback_manager import run_fallback
134
+
135
+ cat = classify_exception(e)
136
+ record_error(cat, str(e), f"get_agents_with_status:{agent_id}", exc=e)
137
+ status = run_fallback(cat, agent_id=agent_id) or 'idle'
114
138
  current_task = ''
115
-
116
- # 获取最后活跃时间
117
- last_active = get_last_active_time(agent_id)
118
-
119
- # 获取错误信息
120
- last_error = get_last_error(agent_id) if status == 'down' else None
121
-
139
+ last_active = 0
140
+ last_error = None
141
+
122
142
  result.append({
123
143
  'id': agent_id,
124
144
  'name': agent.get('name'),
@@ -128,7 +148,7 @@ def get_agents_with_status() -> list:
128
148
  'lastActiveAt': last_active,
129
149
  'error': last_error
130
150
  })
131
-
151
+
132
152
  return result
133
153
 
134
154
 
@@ -0,0 +1,87 @@
1
+ """Shared pytest fixtures for backend tests."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ BACKEND = Path(__file__).resolve().parent.parent
11
+ sys.path.insert(0, str(BACKEND))
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def reset_fortify_state():
16
+ """Reset all fortify singletons between tests."""
17
+ from core.config_fortify import refresh_fortify_config_cache
18
+ from core.error_handler import reset_reliability_metrics_for_tests
19
+ from core.fallback_manager import reset_fallback_handlers_for_tests
20
+ from status.status_cache import reset_cache_for_tests
21
+
22
+ reset_cache_for_tests()
23
+ reset_fallback_handlers_for_tests()
24
+ reset_reliability_metrics_for_tests()
25
+ refresh_fortify_config_cache()
26
+ yield
27
+ reset_cache_for_tests()
28
+ reset_fallback_handlers_for_tests()
29
+ reset_reliability_metrics_for_tests()
30
+ refresh_fortify_config_cache()
31
+
32
+
33
+ @pytest.fixture
34
+ def fake_openclaw_root(tmp_path: Path):
35
+ """Minimal fake openclaw root with sessions.json index and JSONL fixtures."""
36
+ root = tmp_path / ".openclaw"
37
+ root.mkdir(parents=True, exist_ok=True)
38
+
39
+ agents_dir = root / "agents"
40
+ agents_dir.mkdir(exist_ok=True)
41
+
42
+ main_agent = agents_dir / "main"
43
+ main_agent.mkdir(exist_ok=True)
44
+
45
+ # sessions/ subdirectory (canonical path: agents/<id>/sessions/)
46
+ sessions_dir = main_agent / "sessions"
47
+ sessions_dir.mkdir(exist_ok=True)
48
+
49
+ # sessions.json index
50
+ sessions_index = {
51
+ "sessions": [
52
+ {
53
+ "id": "session-001",
54
+ "status": "active",
55
+ "updatedAt": 1746000000,
56
+ "turns": 3,
57
+ },
58
+ {
59
+ "id": "session-002",
60
+ "status": "completed",
61
+ "updatedAt": 1745900000,
62
+ "turns": 7,
63
+ },
64
+ ]
65
+ }
66
+ sessions_file = sessions_dir / "sessions.json"
67
+ sessions_file.write_text(json.dumps(sessions_index))
68
+
69
+ # JSONL session file (in sessions/ subdirectory)
70
+ session_jsonl = sessions_dir / "session-001.jsonl"
71
+ messages = [
72
+ {"type": "start", "sessionId": "session-001", "timestamp": 1746000000},
73
+ {"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": "hello"}]}},
74
+ {"type": "message", "message": {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}},
75
+ ]
76
+ session_jsonl.write_text("\n".join(json.dumps(m) for m in messages) + "\n")
77
+
78
+ # JSONL with repaired line (trailing comma) — CA-003 fixture
79
+ session_with_bad = sessions_dir / "session-002.jsonl"
80
+ bad_messages = [
81
+ json.dumps({"type": "start", "sessionId": "session-002"}),
82
+ '{"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": "test"}]}}',
83
+ '{"type": "end", "sessionId": "session-002", "status": "ok"}',
84
+ ]
85
+ session_with_bad.write_text("\n".join(bad_messages) + "\n")
86
+
87
+ return root
@@ -0,0 +1,372 @@
1
+ """CA-001 / CA-002 / CA-003:核心 API 与数据校验响应结构契约(轻量回归)。"""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ BACKEND = Path(__file__).resolve().parent.parent
10
+ sys.path.insert(0, str(BACKEND))
11
+
12
+
13
+ def _stub_watcher(monkeypatch):
14
+ import watchers.file_watcher as fw
15
+ monkeypatch.setattr(fw, "start_file_watcher", lambda loop: None)
16
+ monkeypatch.setattr(fw, "stop_file_watcher", lambda: None)
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_contract_health_root(monkeypatch):
21
+ import httpx
22
+ _stub_watcher(monkeypatch)
23
+ from main import app
24
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
25
+ r = await c.get("/health")
26
+ assert r.status_code == 200
27
+ assert r.json().get("status") == "healthy"
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_contract_api_version_shape(monkeypatch):
32
+ import httpx
33
+ _stub_watcher(monkeypatch)
34
+ from main import app
35
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
36
+ r = await c.get("/api/version")
37
+ assert r.status_code == 200
38
+ body = r.json()
39
+ assert "version" in body and "name" in body and "description" in body
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_contract_cache_stats_fortify_keys(monkeypatch):
44
+ import httpx
45
+ _stub_watcher(monkeypatch)
46
+ from main import app
47
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
48
+ r = await c.get("/api/cache/stats")
49
+ assert r.status_code == 200
50
+ j = r.json()
51
+ for k in ("hit_rate", "ttl_seconds", "stats", "cache_double_check", "fp_probe_interval_sec"):
52
+ assert k in j, f"missing {k}"
53
+ assert "hits" in j["stats"]
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_contract_errors_stats_has_framework(monkeypatch):
58
+ import httpx
59
+ _stub_watcher(monkeypatch)
60
+ from main import app
61
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
62
+ r = await c.get("/api/errors/stats")
63
+ assert r.status_code == 200
64
+ j = r.json()
65
+ assert "framework" in j
66
+ fw = j["framework"]
67
+ assert "total_count" in fw
68
+ assert "retry_budget_blocks" in fw
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_contract_agents_list_is_array(monkeypatch):
73
+ import httpx
74
+ _stub_watcher(monkeypatch)
75
+ from main import app
76
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
77
+ r = await c.get("/api/agents")
78
+ assert r.status_code == 200
79
+ data = r.json()
80
+ assert isinstance(data, list)
81
+ if data:
82
+ row = data[0]
83
+ for k in ("id", "status", "name"):
84
+ assert k in row
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_contract_health_watcher_shape(monkeypatch):
89
+ import httpx
90
+ _stub_watcher(monkeypatch)
91
+ from main import app
92
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
93
+ r = await c.get("/api/health/watcher")
94
+ assert r.status_code == 200
95
+ j = r.json()
96
+ for k in (
97
+ "status",
98
+ "mode",
99
+ "error_count",
100
+ "switch_count",
101
+ "resume_watchdog_success_count",
102
+ "resume_watchdog_failure_count",
103
+ "uptime_seconds",
104
+ "poll_interval_sec",
105
+ "persisted_snapshot",
106
+ ):
107
+ assert k in j, f"missing {k}"
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_contract_api_config_has_main_agent_id(monkeypatch):
112
+ import httpx
113
+ _stub_watcher(monkeypatch)
114
+ from main import app
115
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
116
+ r = await c.get("/api/config")
117
+ assert r.status_code == 200
118
+ j = r.json()
119
+ assert "mainAgentId" in j
120
+ assert isinstance(j["mainAgentId"], str)
121
+ assert j["mainAgentId"]
122
+
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_contract_data_validate_report_shape(monkeypatch, tmp_path):
126
+ """CA-003:GET /api/data/validate 稳定字段(与 session_reader 报告结构对齐)。"""
127
+ import httpx
128
+ import data.session_reader as session_reader
129
+ monkeypatch.setattr(session_reader, "get_openclaw_root", lambda: tmp_path)
130
+ _stub_watcher(monkeypatch)
131
+ from main import app
132
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
133
+ r = await c.get("/api/data/validate", params={"agent_id": "main"})
134
+ assert r.status_code == 200
135
+ j = r.json()
136
+ for k in (
137
+ "agent_id",
138
+ "validation_passed",
139
+ "sessions_index_path",
140
+ "session_file",
141
+ "session_file_query",
142
+ "file_integrity",
143
+ "read_path_policy",
144
+ "total_lines",
145
+ "valid_messages",
146
+ "repaired_messages",
147
+ "errors",
148
+ "repair_report",
149
+ ):
150
+ assert k in j, f"missing {k}"
151
+ rp = j["repair_report"]
152
+ assert "repaired_count" in rp and "repair_success_rate" in rp and "failed_repairs" in rp
153
+ assert isinstance(j["errors"], list)
154
+ assert isinstance(rp["failed_repairs"], list)
155
+ pol = j["read_path_policy"]
156
+ assert "memory_auto_repair_default" in pol and "disk_write_back_enabled" in pol
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # CA-001 / CA-002:timeline / chains 响应结构契约
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def _stub_root(monkeypatch, tmp_path):
164
+ """隔离 openclaw_root,避免依赖真实数据目录。"""
165
+ _stub_watcher(monkeypatch)
166
+ import data.session_reader as sr
167
+ import api.timeline as tl
168
+ monkeypatch.setattr(sr, "get_openclaw_root", lambda: tmp_path)
169
+ monkeypatch.setattr(tl, "get_agent_config", lambda agent_id: {"id": agent_id, "name": agent_id, "model": "test-model"})
170
+
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_contract_timeline_shape(monkeypatch, tmp_path):
174
+ """CA-002:GET /timeline/{agent_id} 稳定字段(agentId / steps / stats / model / agentName)。"""
175
+ import httpx
176
+ _stub_root(monkeypatch, tmp_path)
177
+ from main import app
178
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
179
+ r = await c.get("/api/timeline/main")
180
+ assert r.status_code == 200
181
+ j = r.json()
182
+ for k in ("agentId", "status", "steps", "stats"):
183
+ assert k in j, f"missing {k}"
184
+ stats = j["stats"]
185
+ for k in ("totalDuration", "totalInputTokens", "totalOutputTokens", "toolCallCount", "stepCount"):
186
+ assert k in stats, f"missing stats.{k}"
187
+ assert isinstance(j["steps"], list)
188
+ assert isinstance(j["agentId"], str)
189
+
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_contract_timeline_steps_shape(monkeypatch, tmp_path):
193
+ """CA-002:GET /timeline/{agent_id}/steps 返回 {steps, count}。"""
194
+ import httpx
195
+ _stub_root(monkeypatch, tmp_path)
196
+ from main import app
197
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
198
+ r = await c.get("/api/timeline/main/steps")
199
+ assert r.status_code == 200
200
+ j = r.json()
201
+ assert "steps" in j
202
+ assert "count" in j
203
+ assert j["count"] == len(j["steps"])
204
+ assert isinstance(j["steps"], list)
205
+
206
+
207
+ @pytest.mark.asyncio
208
+ async def test_contract_chains_list_shape(monkeypatch, tmp_path):
209
+ """CA-002:GET /chains 返回 {chains, activeChain}。"""
210
+ import httpx
211
+ _stub_root(monkeypatch, tmp_path)
212
+ from main import app
213
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
214
+ r = await c.get("/api/chains")
215
+ assert r.status_code == 200
216
+ j = r.json()
217
+ assert "chains" in j
218
+ assert "activeChain" in j
219
+ assert isinstance(j["chains"], list)
220
+ assert j["activeChain"] is None or isinstance(j["activeChain"], dict)
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_contract_chains_summary_shape(monkeypatch, tmp_path):
225
+ """CA-002:GET /chains/summary 返回 {total, running, completed, error, chains}。"""
226
+ import httpx
227
+ _stub_root(monkeypatch, tmp_path)
228
+ from main import app
229
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
230
+ r = await c.get("/api/chains/summary")
231
+ assert r.status_code == 200
232
+ j = r.json()
233
+ for k in ("total", "running", "completed", "error", "chains"):
234
+ assert k in j, f"missing {k}"
235
+ assert isinstance(j["total"], int)
236
+ assert isinstance(j["chains"], list)
237
+
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_contract_chains_active_shape(monkeypatch, tmp_path):
241
+ """CA-002:GET /chains/active 返回 {activeChain} 或含 message。"""
242
+ import httpx
243
+ _stub_root(monkeypatch, tmp_path)
244
+ from main import app
245
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
246
+ r = await c.get("/api/chains/active")
247
+ assert r.status_code == 200
248
+ j = r.json()
249
+ assert "activeChain" in j
250
+ if j["activeChain"] is None:
251
+ assert "message" in j
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # CA-003:数据校验 fixture 驱动测试
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_contract_data_validate_with_fixture(monkeypatch, fake_openclaw_root):
261
+ """CA-003:GET /api/data/validate 用真实 fixture 验证 sessions.json + JSONL 解析。"""
262
+ import httpx
263
+ import data.session_reader as sr
264
+ import api.timeline as tl
265
+
266
+ monkeypatch.setattr(sr, "get_openclaw_root", lambda: fake_openclaw_root)
267
+ monkeypatch.setattr(tl, "get_agent_config", lambda agent_id: {"id": agent_id, "name": agent_id, "model": "test-model"})
268
+ _stub_watcher(monkeypatch)
269
+ from main import app
270
+
271
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
272
+ r = await c.get("/api/data/validate", params={"agent_id": "main"})
273
+ assert r.status_code == 200, r.text
274
+ j = r.json()
275
+ # sessions_index 被找到
276
+ assert j["sessions_index_path"] is not None
277
+ # JSONL 行被解析(valid_messages = lines with type=message)
278
+ assert j["total_lines"] >= 3
279
+ assert j["valid_messages"] >= 1
280
+ assert "file_integrity" in j
281
+
282
+
283
+ @pytest.mark.asyncio
284
+ async def test_contract_data_validate_session_file_param(monkeypatch, fake_openclaw_root):
285
+ """CA-003:GET /api/data/validate 支持 session_file query 参数(无 agent_id 时走默认)。"""
286
+ import httpx
287
+ import data.session_reader as sr
288
+ import api.timeline as tl
289
+
290
+ monkeypatch.setattr(sr, "get_openclaw_root", lambda: fake_openclaw_root)
291
+ monkeypatch.setattr(tl, "get_agent_config", lambda agent_id: {"id": agent_id, "name": agent_id, "model": "test-model"})
292
+ _stub_watcher(monkeypatch)
293
+ from main import app
294
+
295
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
296
+ r = await c.get("/api/data/validate", params={"agent_id": "main", "max_lines": 5})
297
+ assert r.status_code == 200
298
+ j = r.json()
299
+ assert "validation_passed" in j
300
+ assert "repair_report" in j
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # CA-002 扩展:agents / cache / errors 更多字段断言
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ @pytest.mark.asyncio
309
+ async def test_contract_agents_list_item_extended_fields(monkeypatch):
310
+ """CA-002:GET /api/agents 列表项含 status / name / role。"""
311
+ import httpx
312
+ _stub_watcher(monkeypatch)
313
+ from main import app
314
+
315
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
316
+ r = await c.get("/api/agents")
317
+ assert r.status_code == 200
318
+ data = r.json()
319
+ assert isinstance(data, list)
320
+ if data:
321
+ row = data[0]
322
+ for k in ("id", "status", "name", "role"):
323
+ assert k in row, f"missing field: {k}"
324
+
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_contract_cache_stats_stats_fields(monkeypatch):
328
+ """CA-002:GET /api/cache/stats.stats 含 hits / misses / fp_invalidations / stale_fallback_reads。"""
329
+ import httpx
330
+ _stub_watcher(monkeypatch)
331
+ from main import app
332
+
333
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
334
+ r = await c.get("/api/cache/stats")
335
+ assert r.status_code == 200
336
+ stats = r.json()["stats"]
337
+ for k in ("hits", "misses", "fp_invalidations", "stale_fallback_reads"):
338
+ assert k in stats, f"missing stats.{k}"
339
+ assert isinstance(stats[k], int)
340
+
341
+
342
+ @pytest.mark.asyncio
343
+ async def test_contract_errors_stats_by_type_fields(monkeypatch):
344
+ """CA-002:GET /api/errors/stats.framework 含 by_type / by_scope_top / retry_budget_blocks。"""
345
+ import httpx
346
+ _stub_watcher(monkeypatch)
347
+ from main import app
348
+
349
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
350
+ r = await c.get("/api/errors/stats")
351
+ assert r.status_code == 200
352
+ fw = r.json()["framework"]
353
+ assert "by_type" in fw
354
+ assert "by_scope_top" in fw
355
+ assert "retry_budget_blocks" in fw
356
+ assert isinstance(fw["by_type"], dict)
357
+ assert isinstance(fw["by_scope_top"], list)
358
+
359
+
360
+ @pytest.mark.asyncio
361
+ async def test_contract_watcher_poll_ticks_counter(monkeypatch):
362
+ """CA-002:GET /api/health/watcher 含 persisted_snapshot(内含 poll_ticks_counter)。"""
363
+ import httpx
364
+ _stub_watcher(monkeypatch)
365
+ from main import app
366
+
367
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c:
368
+ r = await c.get("/api/health/watcher")
369
+ assert r.status_code == 200
370
+ snap = r.json().get("persisted_snapshot")
371
+ if snap:
372
+ assert "poll_ticks_counter" in snap, "persisted_snapshot should contain poll_ticks_counter"