feat: 持久化诊断历史并修复 Agent 命令执行
This commit is contained in:
@@ -27,8 +27,11 @@ class DiagnosticAgentTest(unittest.TestCase):
|
||||
return await collect_events(run_diagnostic_agent("report", max_steps=3))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual([event["type"] for event in events], ["thinking", "command", "result", "thinking", "final"])
|
||||
self.assertEqual(events[1]["mode"], "read")
|
||||
self.assertEqual(
|
||||
[event["type"] for event in events],
|
||||
["command", "result", "thinking", "command", "result", "thinking", "final"],
|
||||
)
|
||||
self.assertEqual(events[3]["mode"], "read")
|
||||
self.assertEqual(events[-1]["content"], "发现一个异常 Pod")
|
||||
|
||||
def test_pauses_mutating_command_for_human_approval(self):
|
||||
@@ -63,13 +66,18 @@ class DiagnosticAgentTest(unittest.TestCase):
|
||||
async def fake_completion(messages):
|
||||
return '{"action":"command","command":"kubectl delete pod api-0 -n prod","reason":"删除故障 Pod"}'
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl") as execute:
|
||||
events = await collect_events(run_diagnostic_agent("report", max_steps=3, execution_mode="ai"))
|
||||
return events, execute
|
||||
executed = []
|
||||
|
||||
events, execute = asyncio.run(run())
|
||||
execute.assert_not_called()
|
||||
async def run():
|
||||
async def fake_execute(command):
|
||||
executed.append(command)
|
||||
return {"command": command, "mode": "read", "returncode": 0, "stdout": "nodes ready", "stderr": ""}
|
||||
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl", fake_execute):
|
||||
return await collect_events(run_diagnostic_agent("report", max_steps=3, execution_mode="ai"))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual(executed, ["kubectl get nodes -o wide"])
|
||||
self.assertEqual(events[-1]["type"], "approval_required")
|
||||
|
||||
def test_rejects_unknown_execution_mode(self):
|
||||
@@ -80,6 +88,39 @@ class DiagnosticAgentTest(unittest.TestCase):
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertIn("执行模式", events[-1]["message"])
|
||||
|
||||
def test_ai_mode_forces_initial_read_only_probe_before_model_planning(self):
|
||||
responses = iter([
|
||||
'{"action":"final","analysis":"根据采集结果完成分析"}',
|
||||
])
|
||||
|
||||
async def fake_completion(messages):
|
||||
return next(responses)
|
||||
|
||||
executed = []
|
||||
|
||||
async def fake_execute(command):
|
||||
executed.append(command)
|
||||
return {"command": command, "mode": "read", "returncode": 0, "stdout": "node-a Ready", "stderr": ""}
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl", fake_execute):
|
||||
return await collect_events(run_diagnostic_agent("report", max_steps=3, execution_mode="ai"))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual(executed, ["kubectl get nodes -o wide"])
|
||||
self.assertEqual([event["type"] for event in events[:2]], ["command", "result"])
|
||||
self.assertEqual(events[-1]["type"], "final")
|
||||
|
||||
def test_parse_agent_action_accepts_markdown_json_fence(self):
|
||||
from backend.ai_agent import _parse_agent_action
|
||||
|
||||
action = _parse_agent_action(
|
||||
'我将先检查 Pod。\n```json\n{"action":"command","command":"kubectl get pods -A","reason":"查看 Pod"}\n```'
|
||||
)
|
||||
|
||||
self.assertEqual(action["action"], "command")
|
||||
self.assertEqual(action["command"], "kubectl get pods -A")
|
||||
|
||||
def test_stops_when_model_returns_invalid_action(self):
|
||||
async def fake_completion(messages):
|
||||
return "not-json"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import backend.main as main
|
||||
from backend.history_store import HistoryStore
|
||||
|
||||
|
||||
class HistoryApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
self.store = HistoryStore(self.path)
|
||||
self.original_store = main.history_store
|
||||
main.history_store = self.store
|
||||
|
||||
def tearDown(self):
|
||||
main.history_store = self.original_store
|
||||
os.remove(self.path)
|
||||
|
||||
def test_lists_and_gets_full_diagnosis_history(self):
|
||||
record_id = self.store.save_diagnosis(
|
||||
{"timestamp": "2026-07-25T12:00:00", "namespace": "prod", "score": 50, "status": "critical"},
|
||||
"report",
|
||||
)
|
||||
|
||||
records = asyncio.run(main.list_diagnosis_history())
|
||||
detail = asyncio.run(main.get_diagnosis_history(record_id))
|
||||
|
||||
self.assertEqual(records["total"], 1)
|
||||
self.assertEqual(records["items"][0]["id"], record_id)
|
||||
self.assertEqual(detail["report"], "report")
|
||||
|
||||
def test_get_history_returns_404_for_unknown_id(self):
|
||||
with self.assertRaises(main.HTTPException) as context:
|
||||
asyncio.run(main.get_diagnosis_history("missing"))
|
||||
self.assertEqual(context.exception.status_code, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from backend.history_store import HistoryStore
|
||||
|
||||
|
||||
class HistoryStoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
self.store = HistoryStore(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
os.remove(self.path)
|
||||
|
||||
def test_persists_diagnosis_and_can_query_after_reopen(self):
|
||||
diagnosis = {
|
||||
"timestamp": "2026-07-25T12:00:00",
|
||||
"namespace": "prod",
|
||||
"score": 65,
|
||||
"status": "warning",
|
||||
"critical_count": 1,
|
||||
"warning_count": 4,
|
||||
"issues": [{"level": "critical", "message": "pod failed"}],
|
||||
"checks": {},
|
||||
}
|
||||
record_id = self.store.save_diagnosis(diagnosis, "report text")
|
||||
|
||||
reopened = HistoryStore(self.path)
|
||||
records = reopened.list_diagnoses()
|
||||
detail = reopened.get_diagnosis(record_id)
|
||||
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0]["namespace"], "prod")
|
||||
self.assertEqual(detail["diagnosis"]["score"], 65)
|
||||
self.assertEqual(detail["report"], "report text")
|
||||
|
||||
def test_records_all_processing_events_in_order(self):
|
||||
diagnosis_id = self.store.save_diagnosis(
|
||||
{"timestamp": "2026-07-25T12:00:00", "namespace": "all", "score": 80, "status": "warning"},
|
||||
"report",
|
||||
)
|
||||
run_id = self.store.create_run(diagnosis_id, "agent", "ai", "check pods")
|
||||
self.store.append_event(run_id, "command", {"command": "kubectl get pods -A"})
|
||||
self.store.append_event(run_id, "result", {"returncode": 0, "stdout": "ok"})
|
||||
self.store.finish_run(run_id, "completed", "done")
|
||||
|
||||
detail = self.store.get_diagnosis(diagnosis_id)
|
||||
|
||||
self.assertEqual(len(detail["runs"]), 1)
|
||||
self.assertEqual(detail["runs"][0]["status"], "completed")
|
||||
self.assertEqual([event["type"] for event in detail["runs"][0]["events"]], ["command", "result"])
|
||||
|
||||
def test_lists_all_diagnoses_newest_first_and_filters_status(self):
|
||||
first = {"timestamp": "2026-07-25T11:00:00", "namespace": "dev", "score": 100, "status": "healthy"}
|
||||
second = {"timestamp": "2026-07-25T12:00:00", "namespace": "prod", "score": 20, "status": "critical"}
|
||||
self.store.save_diagnosis(first, "first")
|
||||
self.store.save_diagnosis(second, "second")
|
||||
|
||||
all_records = self.store.list_diagnoses()
|
||||
critical = self.store.list_diagnoses(status="critical")
|
||||
|
||||
self.assertEqual([item["namespace"] for item in all_records], ["prod", "dev"])
|
||||
self.assertEqual([item["namespace"] for item in critical], ["prod"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user