import json import sys import types from pathlib import Path import pytest def _install_acp_stubs(): acp_module = types.ModuleType("acp") acp_module.PROTOCOL_VERSION = 1 acp_module.Client = object acp_module.RequestError = Exception acp_module.connect_to_agent = object acp_module.text_block = lambda text: text sys.modules.setdefault("acp", acp_module) core_module = types.ModuleType("acp.core") core_module.ClientSideConnection = object sys.modules.setdefault("acp.core", core_module) schema_module = types.ModuleType("acp.schema") for name in ( "AgentMessageChunk", "AgentPlanUpdate", "AgentThoughtChunk", "AllowedOutcome", "AvailableCommandsUpdate", "ClientCapabilities", "CurrentModeUpdate", "DeniedOutcome", "EmbeddedResourceContentBlock", "FileSystemCapabilities", "PermissionOption", "ReadTextFileResponse", "RequestPermissionResponse", "ResourceContentBlock", "TextContentBlock", "ToolCall", "ToolCallProgress", "ToolCallStart", "UsageUpdate", "UserMessageChunk", "WriteTextFileResponse", ): setattr(schema_module, name, type(name, (), {})) sys.modules.setdefault("acp.schema", schema_module) _install_acp_stubs() from investibook.api.files import _hydrated_report_response # noqa: E402 from investibook.core.agent_runtime import _hydrate_diagnosis_report, _hydrate_report_artifact # noqa: E402 from investibook.core import workspace_evidence_store as analysis_module # noqa: E402 def _load_update_report_analysis_module(): return analysis_module def test_hydrate_diagnosis_report_uses_agent_summary_metrics_without_chart_data(tmp_path): report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "evidence_summary_for_agent.json").write_text( json.dumps( { "metadata": {"cluster_id": "c1"}, "metrics": [ { "name": "CPU Usage", "instance": "db-tidb-0", "peak": 3.5, "unit": "cores", "severity": "critical", "promql": "rate(process_cpu_seconds_total[1m])", "chart_data": {"series": [{"values": [["1", "3.5"]]}]}, } ], } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"]["metrics"] == [ { "name": "CPU Usage", "instance": "db-tidb-0", "peak": 3.5, "unit": "cores", "severity": "critical", "promql": "rate(process_cpu_seconds_total[1m])", } ] def test_hydrate_diagnosis_report_empty_metrics_without_agent_summary(tmp_path): report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "metrics.json").write_text( json.dumps( { "metrics": [ { "name": "CPU Usage", "instance": "db-tidb-raw", "peak": 9.9, "chart_data": {"series": [{"values": [["1", "9.9"]]}]}, } ] } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"]["metrics"] == [] def test_hydrate_diagnosis_report_strips_primary_report_chart_data(tmp_path): report_path = tmp_path / "diagnosis_report.json" report = { "type": "diagnosis_report", "evidence": { "metrics": [ { "name": "CPU Usage", "instance": "db-tidb-primary", "peak": 8.8, "unit": "cores", "chart_data": {"series": [{"values": [["1", "8.8"]]}]}, } ] }, } report_path.write_text(json.dumps(report), encoding="utf-8") hydrated = _hydrate_diagnosis_report(report_path, report) assert hydrated["evidence"]["metrics"] == [ { "name": "CPU Usage", "instance": "db-tidb-primary", "peak": 8.8, "unit": "cores", } ] def test_hydrate_diagnosis_report_uses_log_counts_from_agent_summary(tmp_path): report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "evidence_summary_for_agent.json").write_text( json.dumps( { "metadata": {"cluster_id": "c1"}, "logs": { "log_counts": [ { "component": "tidb", "level": "ERROR", "message": "[ERROR] region unavailable", "count": 2, "instances": ["tidb-0", "tidb-1"], "link": "https://clinic.pingcap.com/logs", } ], "warn_count": 7, "loki_url": "https://clinic.pingcap.com/logs", }, } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"]["logs"] == [ { "component": "tidb", "level": "ERROR", "message": "[ERROR] region unavailable", "count": 2, "instances": ["tidb-0", "tidb-1"], "link": "https://clinic.pingcap.com/logs", } ] def test_hydrate_diagnosis_report_reads_logs_sidecar(tmp_path): report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "logs.json").write_text( json.dumps( { "log_counts": [ { "component": "tikv", "level": "WARN", "message": "raft tick slow", "count": 4, "instances": ["db-tikv-0"], } ] } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"]["logs"] == [ { "component": "tikv", "level": "WARN", "message": "raft tick slow", "count": 4, "instances": ["db-tikv-0"], "link": "", } ] assert hydrated["evidence"]["workload"] == [] def test_hydrate_workspace_evidence_report_does_not_read_sidecars(tmp_path): report_path = tmp_path / "workspace_evidence_report.json" report_path.write_text( json.dumps( { "type": "workspace_evidence_report", "analysis": { "key_evidence": [ { "type": "topsql", "digest": "abc", "instance": "db-tidb-0", "reason": "TopSQL consumed most CPU.", } ] }, "evidence": {"metrics": [], "queries": []}, "links": {"topsql_template": "https://clinic/topsql?instance={urlencoded_instance}"}, } ), encoding="utf-8", ) (tmp_path / "metrics.json").write_text( json.dumps( { "metrics": [ { "name": "QPS", "instance": "db-tidb-0", "peak": 123.4, "summary": {"point_count": 2, "max": 123.4}, "time_window": {"start": 1, "end": 2, "step": "60s"}, "chart_data_ref": {"kind": "promql_range", "promql": "up"}, "chart_data": {"series": [{"values": [["1", "123.4"]]}]}, } ] } ), encoding="utf-8", ) (tmp_path / "queries.json").write_text( json.dumps( { "topsql": [ { "digest": "abc", "instance": "db-tidb-0", "cpu_pct": 88.1, "link": "https://clinic/topsql", } ] } ), encoding="utf-8", ) (tmp_path / "links.json").write_text( json.dumps({"topsql_template": "https://clinic/topsql?instance={urlencoded_instance}"}), encoding="utf-8", ) hydrated = _hydrate_report_artifact(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"] == {"metrics": [], "queries": []} assert hydrated["analysis"]["key_evidence"][0]["link"] == "https://clinic/topsql?instance=db-tidb-0" def test_hydrated_report_response_does_not_mutate_workspace_report(tmp_path): report_path = tmp_path / "workspace_evidence_report.json" original = { "type": "workspace_evidence_report", "evidence": {"metrics": [{"name": "QPS", "metric_group_id": "tidb_qps"}]}, "analysis": {}, } report_path.write_text(json.dumps(original, indent=2), encoding="utf-8") response = _hydrated_report_response(report_path) assert response is not None assert json.loads(report_path.read_text(encoding="utf-8")) == original def test_hydrate_diagnosis_report_strips_primary_report_raw_log_entries(tmp_path): report_path = tmp_path / "diagnosis_report.json" report = { "type": "diagnosis_report", "evidence": { "logs": [ { "timestamp": "1713139200", "component": "tidb", "instance": "tidb-0", "message": "[ERROR] region unavailable", }, { "timestamp": "1713139260", "component": "tidb", "instance": "tidb-1", "message": "[ERROR] region unavailable", }, ] }, } report_path.write_text(json.dumps(report), encoding="utf-8") hydrated = _hydrate_diagnosis_report(report_path, report) assert hydrated["evidence"]["logs"] == [ { "component": "tidb", "level": "ERROR", "message": "[ERROR] region unavailable", "count": 2, "instances": ["tidb-0", "tidb-1"], "link": "", } ] def test_hydrate_diagnosis_report_includes_workload_and_resource_control(tmp_path): report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "evidence_summary_for_agent.json").write_text( json.dumps( { "metadata": {"cluster_id": "c1"}, "workload": [ { "sql_type": "Update", "statement_rate_peak": 20.0, "affected_rows_rate_peak": 2.0, } ], "resource_control": { "request_p90": [ { "instance": "tidb-0", "resource_group": "default", "peak": 0.02, "severity": "warning", } ] }, } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"]["workload"] == [ { "sql_type": "Update", "statement_rate_peak": 20.0, "affected_rows_rate_peak": 2.0, } ] assert hydrated["resource_control"]["request_p90"][0]["peak"] == 0.02 # --- New file layout tests --- def test_hydrate_reads_agent_summary(tmp_path): """evidence_summary_for_agent.json is read for metrics.""" report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "evidence_summary_for_agent.json").write_text( json.dumps( { "metadata": {"cluster_id": "c1"}, "metrics": [ {"name": "CPU Usage", "instance": "db-tidb-0", "peak": 5.0, "unit": "cores"}, ], } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["metadata"]["cluster_id"] == "c1" assert hydrated["evidence"]["metrics"][0]["instance"] == "db-tidb-0" def test_hydrate_reads_root_queries(tmp_path): """queries.json is read for slow_queries and topsql.""" report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "queries.json").write_text( json.dumps({"slow_queries": [{"digest": "abc", "source": "slow_query"}]}), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert any(q.get("digest") == "abc" for q in hydrated["evidence"]["queries"]) def test_hydrate_reads_root_logs_with_log_counts(tmp_path): """logs.json with log_counts is read correctly.""" report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "logs.json").write_text( json.dumps({ "log_counts": [ {"component": "tidb", "level": "ERROR", "message": "[ERROR] region unavailable", "count": 5, "instances": ["tidb-0"]}, ], "warn_count": 3, "loki_url": "", }), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["evidence"]["logs"][0]["message"] == "[ERROR] region unavailable" assert hydrated["evidence"]["logs"][0]["count"] == 5 def test_hydrate_reads_from_diagnosis_report_json(tmp_path): """diagnosis_report.json is the primary data source for hydration.""" report_path = tmp_path / "diagnosis_report.json" report_path.write_text( json.dumps( { "type": "diagnosis_report", "metadata": {"cluster_id": "c2", "time_range": {"start": "100", "end": "200"}}, "evidence": { "metrics": [ {"name": "CPU", "instance": "db-tidb-0", "peak": 7.0, "unit": "cores"}, ], "queries": [ {"source": "slow_query", "digest": "xyz", "sql_preview": "SELECT 1"}, ], "logs": [ {"component": "tikv", "level": "ERROR", "message": "panic", "count": 1, "instances": ["tikv-0"]}, ], "workload": [{"sql_type": "Select", "statement_rate_peak": 100.0}], }, "resource_control": {"rcu_usage": [{"name": "default", "peak": 5000}]}, "gate_checks": {"infra": "INFRA CLEAR"}, } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["metadata"]["cluster_id"] == "c2" assert hydrated["evidence"]["metrics"][0]["peak"] == 7.0 assert hydrated["evidence"]["queries"][0]["digest"] == "xyz" assert hydrated["evidence"]["logs"][0]["message"] == "panic" assert hydrated["evidence"]["workload"][0]["sql_type"] == "Select" assert hydrated["resource_control"]["rcu_usage"][0]["peak"] == 5000 assert hydrated["gate_checks"]["infra"] == "INFRA CLEAR" assert "links" not in hydrated def test_hydrate_reads_queries_from_agent_summary(tmp_path): """When queries are nested inside agent summary under 'queries' key.""" report_path = tmp_path / "diagnosis_report.json" report_path.write_text(json.dumps({"type": "diagnosis_report"}), encoding="utf-8") (tmp_path / "evidence_summary_for_agent.json").write_text( json.dumps( { "metadata": {"cluster_id": "c1"}, "metrics": [], "queries": { "slow_queries": [ {"digest": "abc", "source": "slow_query", "sql_preview": "SELECT 1"}, ], "topsql": [ {"digest": "def", "source": "topsql", "sql_preview": "UPDATE t SET x=1"}, ], "decoded_plan": {}, }, } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) digests = [q["digest"] for q in hydrated["evidence"]["queries"]] assert "abc" in digests assert "def" in digests def test_hydrate_preserves_agent_authored_analysis_groups(tmp_path): """Sidecar evidence hydration must not replace agent-authored analysis.""" report_path = tmp_path / "diagnosis_report.json" report = { "type": "diagnosis_report", "analysis": { "root_cause": "TiKV read hotspot", "severity": "High", "key_evidence": [ { "type": "metric", "name": "TiKV CPU usage", "instance": "tikv-0", "reason": "Only this instance stayed above 80% during the incident.", "link": "https://clinic.example/metrics", } ], "supporting_evidence": [ { "type": "query", "digest": "digest-1", "reason": "TopSQL corroborates CPU attribution.", "link": "https://clinic.example/topsql", } ], }, "evidence": { "metrics": [ {"name": "Primary CPU", "instance": "tikv-0", "peak": 8.0}, ], }, } report_path.write_text(json.dumps(report), encoding="utf-8") (tmp_path / "evidence_summary_for_agent.json").write_text( json.dumps( { "metadata": {"cluster_id": "c1"}, "metrics": [ {"name": "Sidecar CPU", "instance": "tikv-1", "peak": 4.0}, ], } ), encoding="utf-8", ) hydrated = _hydrate_diagnosis_report(report_path, json.loads(report_path.read_text())) assert hydrated["analysis"]["root_cause"] == "TiKV read hotspot" assert hydrated["analysis"]["key_evidence"] == report["analysis"]["key_evidence"] assert hydrated["analysis"]["supporting_evidence"] == report["analysis"]["supporting_evidence"] def test_update_report_analysis_merges_patch_without_replacing_report_fields(): module = _load_update_report_analysis_module() report = { "type": "diagnosis_report", "metadata": {"cluster_id": "c1"}, "evidence": {"metrics": [{"name": "CPU", "peak": 8.0}]}, "analysis": {"root_cause": "old", "severity": "Medium"}, } patch = { "severity": "High", "key_evidence": [ { "type": "metric", "name": "CPU", "reason": "CPU aligns with the incident window.", } ], "supporting_evidence": [ { "type": "log", "name": "No fatal logs", "reason": "Rules out process crash as the primary cause.", } ], } merged = module.merge_analysis(report, patch) assert merged["metadata"] == report["metadata"] assert merged["evidence"] == report["evidence"] # root_cause not in patch — old value preserved assert merged["analysis"]["root_cause"] == "old" assert merged["analysis"]["severity"] == "High" assert merged["analysis"]["key_evidence"] == patch["key_evidence"] assert merged["analysis"]["supporting_evidence"] == patch["supporting_evidence"] def test_update_report_analysis_supports_incremental_add_remove(): module = _load_update_report_analysis_module() report = { "type": "diagnosis_report", "analysis": { "severity": "Medium", "key_evidence": [ { "type": "metric", "name": "Old CPU", "instance": "tikv-0", "reason": "Old reason", }, { "type": "log", "name": "Keep me", "reason": "Still valid", }, ], "supporting_evidence": [ { "type": "topsql", "digest": "digest-1", "instance": "db-tidb-0", "reason": "Old supporting reason", } ], }, } patch = { "severity": "High", "add_key_evidence": [ { "type": "metric", "name": "New CPU", "instance": "tikv-1", "reason": "Newly confirmed hotspot", } ], "remove_key_evidence": [ { "type": "metric", "name": "Old CPU", "instance": "tikv-0", } ], "add_supporting_evidence": [ { "type": "slow_query", "digest": "digest-2", "reason": "Corroborates the new hotspot theory", } ], "remove_supporting_evidence": [ { "type": "topsql", "digest": "digest-1", "instance": "db-tidb-0", } ], } merged = module.merge_analysis(report, patch) assert merged["analysis"]["severity"] == "High" assert merged["analysis"]["key_evidence"] == [ { "type": "log", "name": "Keep me", "reason": "Still valid", }, { "type": "metric", "name": "New CPU", "instance": "tikv-1", "reason": "Newly confirmed hotspot", }, ] assert merged["analysis"]["supporting_evidence"] == [ { "type": "slow_query", "digest": "digest-2", "reason": "Corroborates the new hotspot theory", } ] def test_update_report_analysis_rejects_invalid_remove_item(): module = _load_update_report_analysis_module() patch = { "remove_key_evidence": [ { "type": "metric", } ], } with pytest.raises(SystemExit, match="must include name or digest"): module.validate_analysis_patch(patch) def test_update_report_analysis_strips_link_from_evidence(): """Validation must strip agent-supplied link — links are generated at hydration.""" module = _load_update_report_analysis_module() patch = { "supporting_evidence": [ { "type": "topsql", "digest": "digest-1", "instance": "db-tidb-0:10080", "reason": "TopSQL identifies the CPU-heavy digest on this instance.", "link": "https://clinic.example/ngm.html#/topsql?instance=db-tidb-0%3A10080&from=1&to=2", } ], } module.validate_analysis_patch(patch) assert "link" not in patch["supporting_evidence"][0] def test_update_report_analysis_accepts_metric_ref(): module = _load_update_report_analysis_module() patch = { "key_evidence": [ { "type": "metric", "name": "TiKV CPU utilization", "metric_ref": { "metric_group_id": "tikv_cpu:db", "instances": ["db-tikv-19", "db-tikv-24"], }, "reason": "The selected TiKV nodes are saturated during the incident.", } ], } module.validate_analysis_patch(patch) def test_update_report_analysis_rejects_invalid_metric_ref_instances(): module = _load_update_report_analysis_module() patch = { "key_evidence": [ { "type": "metric", "name": "TiKV CPU utilization", "metric_ref": { "metric_group_id": "tikv_cpu:db", "instances": "db-tikv-19", }, "reason": "The selected TiKV node is saturated during the incident.", } ], } with pytest.raises(SystemExit, match="metric_ref.instances must be a list of strings"): module.validate_analysis_patch(patch) def test_hydrate_enriches_analysis_evidence_links(tmp_path): """Hydration should add links to topsql and slow_query analysis evidence only.""" report_path = tmp_path / "diagnosis_report.json" # Use the same digest for both slow_query and topsql to verify no cross-contamination shared_digest = "shared-digest-1" report = { "type": "diagnosis_report", "links": { "clinic_overview": "https://clinic.example/portal/#/clusters/c1", "slow_queries": "https://clinic.example/ngm.html#/slow_query", "logs": "https://clinic.example/grafana/explore", "topsql": "https://clinic.example/ngm.html#/topsql", "topsql_template": "https://clinic.example/ngm.html#/topsql?instance={urlencoded_instance}&from=1&to=2", }, "evidence": { "queries": [ {"source": "slow_query", "digest": shared_digest, "link": "https://clinic.example/ngm.html#/slow_query?digest=shared-digest-1"}, {"source": "topsql", "digest": shared_digest, "instance": "tikv-0:20160", "link": "https://clinic.example/ngm.html#/topsql?instance=tikv-0"}, ], "metrics": [], "logs": [], }, "analysis": { "severity": "High", "key_evidence": [ { "type": "topsql", "digest": shared_digest, "instance": "tikv-0:20160", "reason": "Hot SQL on tikv-0.", }, { "type": "metric", "name": "TiKV CPU", "instance": "tikv-0", "reason": "CPU saturated.", }, ], "supporting_evidence": [ { "type": "slow_query", "digest": shared_digest, "reason": "Slow query corroborates.", }, { "type": "log", "name": "region unavailable", "reason": "Logs show region errors.", }, { "type": "slow_query", "digest": "already-linked", "reason": "Should keep existing link.", "link": "https://clinic.example/custom", }, ], }, } report_path.write_text(json.dumps(report), encoding="utf-8") hydrated = _hydrate_diagnosis_report(report_path, report) ke = hydrated["analysis"]["key_evidence"] # topsql: uses topsql_template with URL-encoded instance assert "instance=tikv-0%3A20160" in ke[0]["link"] assert "#/topsql?" in ke[0]["link"] # metric: no link added assert "link" not in ke[1] se = hydrated["analysis"]["supporting_evidence"] # slow_query with same digest: must get slow_query link, not topsql link assert se[0]["link"] == "https://clinic.example/ngm.html#/slow_query?digest=shared-digest-1" assert "#/slow_query?" in se[0]["link"] # log: no link added assert "link" not in se[1] # existing link preserved assert se[2]["link"] == "https://clinic.example/custom"