import asyncio import sys import types from types import SimpleNamespace import pytest from investibook.api import auto_chat as auto_chat_api from investibook.api import workspaces as ws_api from investibook.db import queries as dbq from investibook.models.requests import ( ContinueAutoChatRequest, CreateAlertWorkspaceRequest, StartAutoChatRequest, WorkspaceCreate, ) @pytest.fixture(autouse=True) def patch_workspace_init(monkeypatch): monkeypatch.setattr(ws_api, "_ensure_workspace_dir_initialized", lambda _wid: None) def test_auto_chat_start_status_continue_flow(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_b.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() owner = dbq._db_create_user("phaseb-owner@pingcap.com", "pass-123") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Phase B"), current_user=owner, ) ) workspace_id = created["id"] dbq._db_set_blob( "workspace_sources", workspace_id, [{"id": "src1", "type": "jira_issue", "issueId": "O11Y-9001"}], ) started = [] def _fake_start(ws_id: str) -> None: started.append(ws_id) monkeypatch.setattr(auto_chat_api, "start_auto_chat_task", _fake_start) start_resp = asyncio.run( auto_chat_api.start_auto_chat( workspace_id, StartAutoChatRequest( questions=["Q1", "Q2"], callback_url="https://example.test/callback", ), current_user=owner, ) ) assert start_resp["status"] == "running" assert start_resp["total_questions"] == 2 assert started == [workspace_id] status = asyncio.run( auto_chat_api.get_diagnostics_status(workspace_id, current_user=owner) ) assert status["status"] == "running" assert status["total_questions"] == 2 assert status["current_index"] == 0 assert status["alert_id"] == "O11Y-9001" cont_resp = asyncio.run( auto_chat_api.continue_auto_chat( workspace_id, ContinueAutoChatRequest(resume_from_index=1), current_user=owner, ) ) assert cont_resp["status"] == "running" assert cont_resp["current_index"] == 1 def test_from_alert_with_auto_questions_creates_diagnostics(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_b2.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() monkeypatch.setattr(ws_api, "WORKSPACES_DIR", tmp_path / "workspaces") monkeypatch.setattr(ws_api, "TEMPLATES_DIR", tmp_path / "templates") monkeypatch.setattr(ws_api, "_ensure_workspace_dir_initialized", lambda _wid: None) owner = dbq._db_create_user("phaseb-alert@pingcap.com", "pass-123") # AutoChat should NOT start immediately; it should start only after # the issue source preparation completes (scheme A). started = [] def _fake_start(ws_id: str) -> None: started.append(ws_id) import investibook.api.sources as sources_api import investibook.core.auto_chat as auto_chat_core monkeypatch.setattr(sources_api, "WORKSPACES_DIR", tmp_path / "workspaces") monkeypatch.setattr(sources_api, "TEMPLATES_DIR", tmp_path / "templates") monkeypatch.setattr(auto_chat_core, "start_auto_chat_task", _fake_start) async def _noop_prepare_task(*_args, **_kwargs): return None monkeypatch.setattr(sources_api, "_prepare_issue_source_task", _noop_prepare_task) created = asyncio.run( ws_api.create_workspace_from_alert( CreateAlertWorkspaceRequest( alert_id="O11Y-8123", auto_questions=["Q1", "Q2", "Q3"], callback_url="https://example.test/callback", ), current_user=owner, ) ) ws_id = created["workspace_id"] diag = dbq._db_get_workspace_diagnostics(ws_id) assert diag is not None assert diag["status"] == "pending" assert diag["alert_id"] == "O11Y-8123" assert diag["questions"] == ["Q1", "Q2", "Q3"] assert started == [] @pytest.mark.parametrize( ("source_type", "issue_id"), [("jira_issue", "O11Y-9999"), ("alertstore_alert", "ALT-2608-1742")], ) def test_source_prepare_completion_starts_autochat_for_pending_diagnostics( tmp_path, monkeypatch, source_type, issue_id ): db_path = tmp_path / "ib_test_b5.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() # Avoid importing real services.source_preparer (pulls aioboto3 and heavy deps). _stub_mod = types.ModuleType("services.source_preparer") async def _noop_prepare_issue_source(**_kwargs): return None _stub_mod.prepare_issue_source = _noop_prepare_issue_source monkeypatch.setitem(sys.modules, "services.source_preparer", _stub_mod) import investibook.api.sources as sources_api import investibook.core.auto_chat as auto_chat_core owner = dbq._db_create_user("phaseb-gated@pingcap.com", "pass-123") monkeypatch.setattr(ws_api, "WORKSPACES_DIR", tmp_path / "workspaces") monkeypatch.setattr(ws_api, "TEMPLATES_DIR", tmp_path / "templates") monkeypatch.setattr(ws_api, "_ensure_workspace_dir_initialized", lambda _wid: None) # Capture runner start + callback. started: list[str] = [] def _fake_start(ws_id: str) -> None: started.append(ws_id) emitted: list[tuple[str, str]] = [] async def _fake_emit(ws_id: str, *, status: str, reason: str | None = None): emitted.append((ws_id, status)) return None monkeypatch.setattr(auto_chat_core, "start_auto_chat_task", _fake_start) monkeypatch.setattr(auto_chat_core, "_emit_status_callback", _fake_emit) # Create from-alert with auto_questions -> diagnostics pending. created = asyncio.run( ws_api.create_workspace_from_alert( CreateAlertWorkspaceRequest( alert_id=issue_id, auto_questions=["Q1"], callback_url="https://example.test/callback", ), current_user=owner, ) ) ws_id = created["workspace_id"] # Simulate prepare task completion and the sync-back to sources. task_id = created["source_prepare_task_id"] sources_api._prepare_tasks[task_id] = { "status": "completed", "issue_id": issue_id, "workspace_id": ws_id, "files": None, "cluster_meta": None, "issue_dir": f"issues/{issue_id}", "error": None, } # Seed an initial source record matching the task (like from-alert does). dbq._db_set_blob( "workspace_sources", ws_id, [ { "id": "src-1", "type": source_type, "issueId": issue_id, "status": "loading", "taskId": task_id, } ], ) # Call the real wrapper: it will run _sync_source_status_from_task and then start AutoChat. asyncio.run(sources_api._prepare_issue_source_task(task_id, issue_id, ws_id)) diag = dbq._db_get_workspace_diagnostics(ws_id) assert diag is not None assert diag["status"] == "running" assert started == [ws_id] assert (ws_id, "running") in emitted def test_source_prepare_failure_marks_pending_diagnostics_failed(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_prepare_failed.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() import investibook.api.sources as sources_api import investibook.core.auto_chat as auto_chat_core owner = dbq._db_create_user("prepare-failed@pingcap.com", "pass-123") monkeypatch.setattr(sources_api, "WORKSPACES_DIR", tmp_path / "workspaces") monkeypatch.setattr(sources_api, "TEMPLATES_DIR", tmp_path / "templates") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Prepare failure"), current_user=owner, ) ) workspace_id = created["id"] task_id = "task-prepare-failed" dbq._db_set_blob( "workspace_sources", workspace_id, [ { "id": "src-failed", "type": "jira_issue", "issueId": "ST-404", "status": "loading", "taskId": task_id, } ], ) dbq._db_upsert_workspace_diagnostics( workspace_id, alert_id="ST-404", questions=["diagnose"], status="pending", ) sources_api._prepare_tasks[task_id] = { "status": "processing", "issue_id": "ST-404", "workspace_id": workspace_id, "files": None, "cluster_meta": None, "error": None, "skip_jira_comments": True, } async def _fail_prepare(**kwargs): task = kwargs["prepare_tasks"][kwargs["task_id"]] task["status"] = "error" task["error"] = "Jira unavailable" fake_module = types.ModuleType("services.source_preparer") fake_module.prepare_issue_source = _fail_prepare monkeypatch.setitem(sys.modules, "services.source_preparer", fake_module) callbacks: list[tuple[str, str, str]] = [] async def _fake_callback(ws_id: str, *, status: str, reason: str | None = None): callbacks.append((ws_id, status, reason or "")) monkeypatch.setattr(auto_chat_core, "_emit_status_callback", _fake_callback) asyncio.run(sources_api._prepare_issue_source_task(task_id, "ST-404", workspace_id)) source = dbq._db_get_blob("workspace_sources", workspace_id)[0] diag = dbq._db_get_workspace_diagnostics(workspace_id) assert source["status"] == "error" assert source["taskId"] is None assert diag["status"] == "failed" assert diag["intervention_reason"] == "Source preparation failed: Jira unavailable" assert callbacks == [ (workspace_id, "failed", "Source preparation failed: Jira unavailable") ] def test_emit_status_callback_reports_completed_payload(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_b3.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() owner = dbq._db_create_user("phaseb-callback@pingcap.com", "pass-123") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Callback Case"), current_user=owner, ) ) workspace_id = created["id"] dbq._db_upsert_workspace_diagnostics( workspace_id, alert_id="O11Y-7777", questions=["Q1", "Q2"], current_index=2, callback_url="https://callback.example/status", status="completed", last_response="done", ) captured: dict = {} async def _fake_notify(url: str, payload: dict) -> None: captured["url"] = url captured["payload"] = payload return {"ok": True, "status_code": 200, "error": None} import investibook.core.auto_chat as auto_chat_core monkeypatch.setattr(auto_chat_core, "_notify_callback", _fake_notify) asyncio.run(auto_chat_core._emit_status_callback(workspace_id, status="completed")) assert captured["url"] == "https://callback.example/status" payload = captured["payload"] assert payload["workspace_id"] == workspace_id assert payload["alert_id"] == "O11Y-7777" assert payload["status"] == "completed" assert payload["current_index"] == 2 assert payload["total_questions"] == 2 assert payload["last_response"] == "done" assert payload["event_id"].startswith("evt-") diag = dbq._db_get_workspace_diagnostics(workspace_id) assert diag["last_callback_status"] == "sent_http_200" assert diag["last_callback_error"] is None assert diag["last_callback_at"] is not None status_resp = asyncio.run( auto_chat_api.get_diagnostics_status(workspace_id, current_user=owner) ) assert status_resp["callback"]["configured"] is True assert status_resp["callback"]["url_masked"] == "https://callback.example/status" assert status_resp["callback"]["last_status"] == "sent_http_200" def test_emit_status_callback_includes_assignee_payload(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_b6.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() owner = dbq._db_create_user("phaseb-assignee@pingcap.com", "pass-123") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Callback Assignee"), current_user=owner, ) ) workspace_id = created["id"] dbq._db_upsert_workspace_diagnostics( workspace_id, alert_id="O11Y-7001", questions=["Q1"], current_index=1, callback_url="https://callback.example/status", status="needs_intervention", last_response="needs human", intervention_reason="manual handoff", ) captured: dict = {} async def _fake_notify(url: str, payload: dict) -> None: captured["url"] = url captured["payload"] = payload return {"ok": True, "status_code": 200, "error": None} import investibook.core.auto_chat as auto_chat_core monkeypatch.setattr(auto_chat_core, "_notify_callback", _fake_notify) asyncio.run( auto_chat_core._emit_status_callback( workspace_id, status="needs_intervention", reason="manual handoff", assignee_email="chenjiandong@pingcap.com", ) ) payload = captured["payload"] assert payload["status"] == "needs_intervention" assert payload["assignee_email"] == "chenjiandong@pingcap.com" assert "assignee_domain" not in payload assert "assignee_name_raw" not in payload def test_auto_chat_runner_persists_messages(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_b4.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() owner = dbq._db_create_user("phaseb-messages@pingcap.com", "pass-123") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Runner Messages"), current_user=owner, ) ) workspace_id = created["id"] dbq._db_upsert_workspace_diagnostics( workspace_id, alert_id="O11Y-8888", questions=["Q1", "Q2"], current_index=0, status="running", ) import investibook.core.auto_chat as auto_chat_core answers = ["A1", "A2"] async def _fake_ask(_workspace_id: str, _question: str): return SimpleNamespace(text=answers.pop(0), finish_reason="completed", error=None) async def _fake_emit(_workspace_id: str, *, status: str, reason: str | None = None): return None monkeypatch.setattr(auto_chat_core, "_ask_workspace_question", _fake_ask) monkeypatch.setattr(auto_chat_core, "_emit_status_callback", _fake_emit) asyncio.run(auto_chat_core._run_auto_chat(workspace_id)) messages = dbq._db_get_messages_v2(workspace_id) assert len(messages) == 4 assert [m["role"] for m in messages] == ["user", "assistant", "user", "assistant"] assert [m["content"] for m in messages] == ["Q1", "A1", "Q2", "A2"] diag = dbq._db_get_workspace_diagnostics(workspace_id) assert diag["status"] == "completed" assert diag["current_index"] == 2 def test_messages_v2_can_omit_parts_for_compact_history(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_messages_compact.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() owner = dbq._db_create_user("compact-history@pingcap.com", "pass-123") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Compact History"), current_user=owner, ) ) workspace_id = created["id"] dbq._db_append_messages_v2( workspace_id, [ { "id": "assistant-with-tool", "role": "assistant", "content": "Final answer", "parts": [ {"type": "text", "id": "text-1", "text": "Final answer"}, { "type": "tool", "id": "tool-1", "state": {"status": "completed", "output": "large output"}, }, ], } ], ) full = dbq._db_get_messages_v2(workspace_id) compact = dbq._db_get_messages_v2(workspace_id, include_parts=False) assert full[0]["parts"][1]["id"] == "tool-1" assert "parts" not in compact[0] assert compact[0]["content"] == "Final answer" def test_auto_chat_completion_can_escalate_to_needs_intervention(tmp_path, monkeypatch): db_path = tmp_path / "ib_test_b7.db" monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() owner = dbq._db_create_user("phaseb-escalate@pingcap.com", "pass-123") created = asyncio.run( ws_api.create_workspace_api( WorkspaceCreate(name="Completion Escalation"), current_user=owner, ) ) workspace_id = created["id"] dbq._db_upsert_workspace_diagnostics( workspace_id, alert_id="O11Y-8111", questions=["Q1"], current_index=0, status="running", ) import investibook.core.auto_chat as auto_chat_core async def _fake_ask(_workspace_id: str, _question: str): return SimpleNamespace(text="final analysis text", finish_reason="completed", error=None) async def _fake_eval(_last_response: str): return { "needs_intervention": True, "reason": "need human validation", "domain_tags": ["o11y"], } emitted: list[dict] = [] async def _fake_emit( _workspace_id: str, *, status: str, reason: str | None = None, assignee_email: str | None = None, ): emitted.append( { "status": status, "reason": reason, "assignee_email": assignee_email, } ) return None monkeypatch.setattr(auto_chat_core, "_ask_workspace_question", _fake_ask) monkeypatch.setattr(auto_chat_core, "_evaluate_completion_for_intervention", _fake_eval) monkeypatch.setattr(auto_chat_core, "_emit_status_callback", _fake_emit) asyncio.run(auto_chat_core._run_auto_chat(workspace_id)) diag = dbq._db_get_workspace_diagnostics(workspace_id) assert diag["status"] == "needs_intervention" assert diag.get("oncall_user_id") is None assert emitted assert emitted[-1]["status"] == "needs_intervention" assert emitted[-1]["assignee_email"] == ""