from __future__ import annotations import asyncio import importlib from types import SimpleNamespace from typing import Any import pytest from fastapi import HTTPException from agent.dashboard import review_chat_api # `agent.tools.__init__` rebinds these names to the tool *functions*, shadowing # the submodules. Import the real modules so we can monkeypatch their globals. list_review_findings = importlib.import_module("agent.tools.list_review_findings") read_repo_file = importlib.import_module("agent.tools.read_repo_file") search_repo_code = importlib.import_module("agent.tools.search_repo_code") def _fake_async_client(handler): """Build a fake ``httpx.AsyncClient`` factory whose ``get`` calls ``handler``. ``handler(url, headers=..., params=...)`` returns the response object. """ class _FakeClient: def __init__(self, *args, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def get(self, url, headers=None, params=None): return handler(url, headers=headers, params=params) return _FakeClient # --- chat thread list / delete / title --------------------------------------- def test_derive_title_from_first_user_message() -> None: params = { "input": {"messages": [{"type": "human", "content": " Why did we drop\nstructs? "}]} } assert review_chat_api._derive_title(params) == "Why did we drop structs?" def test_derive_title_defaults_when_no_message() -> None: assert review_chat_api._derive_title({"input": {"messages": []}}) == "New chat" def test_derive_title_truncates() -> None: params = {"input": {"messages": [{"type": "human", "content": "x" * 200}]}} assert len(review_chat_api._derive_title(params)) == review_chat_api._TITLE_MAX_CHARS @pytest.mark.asyncio async def test_list_review_chat_threads_scopes_and_maps(monkeypatch) -> None: captured: dict[str, Any] = {} async def search(**kwargs: Any) -> list[dict[str, Any]]: captured["metadata"] = kwargs.get("metadata") return [ { "thread_id": "c1", "updated_at": "2026-06-15T00:00:00Z", "metadata": {"title": "Why structs?"}, }, {"thread_id": "c2", "metadata": {}}, # untitled -> default label ] client = SimpleNamespace(threads=SimpleNamespace(search=search)) monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client) threads = await review_chat_api.list_review_chat_threads("acme", "repo", 7, "octocat") assert captured["metadata"] == { "kind": "review_chat", "github_login": "octocat", "repo_owner": "acme", "repo_name": "repo", "pr_number": 7, } assert threads[0] == { "thread_id": "c1", "title": "Why structs?", "updated_at": "2026-06-15T00:00:00Z", } assert threads[1]["title"] == "New chat" @pytest.mark.asyncio async def test_delete_review_chat_thread_checks_ownership(monkeypatch) -> None: deleted: list[str] = [] async def get(thread_id: str) -> dict[str, Any]: return { "thread_id": thread_id, "metadata": { "kind": "review_chat", "github_login": "octocat", "repo_owner": "acme", "repo_name": "repo", "pr_number": 7, }, } async def delete(thread_id: str) -> None: deleted.append(thread_id) client = SimpleNamespace(threads=SimpleNamespace(get=get, delete=delete)) monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client) await review_chat_api.delete_review_chat_thread("acme", "repo", 7, "octocat", "c1") assert deleted == ["c1"] @pytest.mark.asyncio async def test_delete_review_chat_thread_rejects_other_user(monkeypatch) -> None: async def get(thread_id: str) -> dict[str, Any]: return { "thread_id": thread_id, "metadata": {"kind": "review_chat", "github_login": "hubot"}, } async def delete(thread_id: str) -> None: raise AssertionError("should not delete another user's chat") client = SimpleNamespace(threads=SimpleNamespace(get=get, delete=delete)) monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client) with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404) await review_chat_api.delete_review_chat_thread("acme", "repo", 7, "octocat", "c1") def _patch_thread_metadata(monkeypatch, metadata: dict[str, Any] | None) -> None: async def get(thread_id: str) -> dict[str, Any]: if metadata is None: raise RuntimeError("not found") return {"thread_id": thread_id, "metadata": metadata} client = SimpleNamespace(threads=SimpleNamespace(get=get)) monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client) @pytest.mark.asyncio async def test_assert_chat_thread_access_allows_owner(monkeypatch) -> None: _patch_thread_metadata( monkeypatch, { "kind": "review_chat", "github_login": "octocat", "repo_owner": "acme", "repo_name": "repo", "pr_number": 7, }, ) meta = await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat") assert meta is not None @pytest.mark.asyncio async def test_assert_chat_thread_access_missing_thread_returns_none(monkeypatch) -> None: _patch_thread_metadata(monkeypatch, None) assert ( await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat") is None ) @pytest.mark.asyncio @pytest.mark.parametrize( "metadata", [ { # another user's chat thread "kind": "review_chat", "github_login": "hubot", "repo_owner": "acme", "repo_name": "repo", "pr_number": 7, }, { # a reviewer (non-chat) thread with the same deterministic id space "kind": "reviewer", "github_login": "octocat", "repo_owner": "acme", "repo_name": "repo", "pr_number": 7, }, { # right user, wrong PR scope "kind": "review_chat", "github_login": "octocat", "repo_owner": "acme", "repo_name": "repo", "pr_number": 8, }, ], ) async def test_assert_chat_thread_access_rejects_unauthorized(monkeypatch, metadata) -> None: _patch_thread_metadata(monkeypatch, metadata) with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404) await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat") # --- tools ------------------------------------------------------------------- @pytest.mark.asyncio async def test_list_review_findings_compacts_and_filters(monkeypatch) -> None: monkeypatch.setattr( list_review_findings, "get_config", lambda: {"configurable": {"reviewer_thread_id": "rt-1"}}, ) async def fake_list(thread_id: str) -> list[dict[str, Any]]: assert thread_id == "rt-1" return [ { "id": "f1", "title": "Open one", "status": "open", "severity": "high", "github_review_comment_id": 999, }, {"id": "f2", "title": "Closed one", "status": "resolved", "severity": "low"}, ] monkeypatch.setattr(list_review_findings, "list_findings_async", fake_list) result = await list_review_findings.list_review_findings(status_filter="open") assert result["count"] == 1 finding = result["findings"][0] assert finding["id"] == "f1" # compact view drops GitHub plumbing fields assert "github_review_comment_id" not in finding @pytest.mark.asyncio async def test_list_review_findings_requires_reviewer_thread(monkeypatch) -> None: monkeypatch.setattr(list_review_findings, "get_config", lambda: {"configurable": {}}) result = await list_review_findings.list_review_findings() assert result["count"] == 0 assert "reviewer thread" in result["error"] @pytest.mark.asyncio async def test_read_repo_file_decodes_file(monkeypatch) -> None: import base64 monkeypatch.setattr( read_repo_file, "get_config", lambda: { "configurable": { "chat_repo_owner": "acme", "chat_repo_name": "repo", "chat_github_token": "tok", "chat_head_sha": "deadbeef", } }, ) captured: dict[str, Any] = {} def fake_get(url, headers=None, params=None): captured["url"] = url captured["params"] = params return SimpleNamespace( status_code=200, json=lambda: {"type": "file", "content": base64.b64encode(b"hello\nworld").decode()}, ) monkeypatch.setattr(read_repo_file.httpx, "AsyncClient", _fake_async_client(fake_get)) result = await read_repo_file.read_repo_file("src/app.py") assert result["success"] is True assert result["content"] == "hello\nworld" assert result["ref"] == "deadbeef" # defaults to head sha assert captured["params"] == {"ref": "deadbeef"} @pytest.mark.asyncio async def test_read_repo_file_lists_directory(monkeypatch) -> None: monkeypatch.setattr( read_repo_file, "get_config", lambda: { "configurable": { "chat_repo_owner": "acme", "chat_repo_name": "repo", "chat_github_token": "tok", } }, ) def fake_get(url, headers=None, params=None): return SimpleNamespace( status_code=200, json=lambda: [ {"name": "a.py", "type": "file", "path": "src/a.py"}, {"name": "sub", "type": "dir", "path": "src/sub"}, ], ) monkeypatch.setattr(read_repo_file.httpx, "AsyncClient", _fake_async_client(fake_get)) result = await read_repo_file.read_repo_file("src") assert result["success"] is True assert {e["name"] for e in result["entries"]} == {"a.py", "sub"} @pytest.mark.asyncio async def test_read_repo_file_missing_context(monkeypatch) -> None: monkeypatch.setattr(read_repo_file, "get_config", lambda: {"configurable": {}}) result = await read_repo_file.read_repo_file("src/app.py") assert result["success"] is False @pytest.mark.asyncio async def test_search_repo_code_scopes_to_repo(monkeypatch) -> None: monkeypatch.setattr( search_repo_code, "get_config", lambda: { "configurable": { "chat_repo_owner": "acme", "chat_repo_name": "repo", "chat_github_token": "tok", } }, ) captured: dict[str, Any] = {} def fake_get(url, headers=None, params=None): captured["params"] = params return SimpleNamespace( status_code=200, json=lambda: { "total_count": 1, "items": [{"path": "src/a.py", "text_matches": [{"fragment": "def foo()"}]}], }, ) monkeypatch.setattr(search_repo_code.httpx, "AsyncClient", _fake_async_client(fake_get)) result = await search_repo_code.search_repo_code("foo") assert result["success"] is True assert "repo:acme/repo" in captured["params"]["q"] assert result["results"][0]["path"] == "src/a.py" # --- proxy enrichment -------------------------------------------------------- def _fake_review() -> dict[str, Any]: return { "title": "Fix things", "number": 7, "full_name": "acme/repo", "author": "octocat", "head_ref": "feature", "base_ref": "main", "head_sha": "abc123def456", "findings": [ { "id": "f1", "title": "Bug", "severity": "high", "confidence": "high", "status": "open", "file": "src/a.py", "start_line": 5, "description": "boom", "group": "bug", }, ], "pr": { "state": "open", "body": "desc", "additions": 1, "deletions": 2, "changed_files": 1, "commits": 1, "head_sha": "abc123def456", }, } def _client_for_enrich(existing_metadata: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]: captured: dict[str, Any] = {"created": False, "updated": []} async def get(thread_id: str) -> dict[str, Any]: if existing_metadata is None: raise RuntimeError("not found") return {"thread_id": thread_id, "metadata": existing_metadata} async def create(**kwargs: Any) -> None: captured["created"] = True async def update(**kwargs: Any) -> None: captured["updated"].append(kwargs.get("metadata")) client = SimpleNamespace(threads=SimpleNamespace(get=get, create=create, update=update)) return client, captured def _patch_enrich_deps( monkeypatch, *, metadata: dict[str, Any] | None, current_head: str = "abc123def456" ) -> dict[str, Any]: client, captured = _client_for_enrich(metadata) monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client) async def fake_get_review(owner, repo, pr_number): return _fake_review() async def fake_diff(*, owner, repo, pr_number, token): return "diff --git a/x b/x\n+added\n" async def fake_token(repositories=None): return "app-token" async def fake_head(owner, repo, pr_number): captured["head_calls"] = captured.get("head_calls", 0) + 1 return current_head monkeypatch.setattr(review_chat_api, "get_review", fake_get_review) monkeypatch.setattr(review_chat_api, "fetch_pr_diff", fake_diff) monkeypatch.setattr(review_chat_api, "get_github_app_installation_token", fake_token) monkeypatch.setattr(review_chat_api, "get_pr_head_sha", fake_head) return captured @pytest.mark.asyncio async def test_enrich_chat_command_seeds_context_on_create(monkeypatch) -> None: _patch_enrich_deps(monkeypatch, metadata=None) command = {"method": "run.start", "params": {"input": {"messages": []}}} enriched = await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1" ) params = enriched["params"] assert params["assistant_id"] == "chat" configurable = params["config"]["configurable"] assert configurable["chat_repo_owner"] == "acme" assert configurable["chat_repo_name"] == "repo" assert configurable["chat_pr_number"] == 7 assert configurable["chat_head_sha"] == "abc123def456" assert configurable["reviewer_thread_id"] == review_chat_api.reviewer_thread_id( "acme", "repo", 7 ) files = params["input"]["files"] assert set(files) == {"/pr/overview.md", "/pr/diff.patch", "/pr/findings.md"} @pytest.mark.asyncio async def test_enrich_chat_command_reuses_context_when_head_unchanged(monkeypatch) -> None: # Stored head matches the current review head -> no reseed. captured = _patch_enrich_deps( monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "abc123def456"} ) command = {"method": "run.start", "params": {"input": {"messages": []}}} enriched = await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1" ) params = enriched["params"] # No re-seeding of files when the head hasn't moved. assert "files" not in params["input"] assert params["config"]["configurable"]["chat_head_sha"] == "abc123def456" assert captured["created"] is False assert captured["updated"] == [] @pytest.mark.asyncio async def test_enrich_chat_command_reseeds_on_head_change(monkeypatch) -> None: # Stored head is stale relative to the current review head -> reseed. captured = _patch_enrich_deps( monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "old-stale-sha"} ) command = {"method": "run.start", "params": {"input": {"messages": []}}} enriched = await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1" ) params = enriched["params"] files = params["input"]["files"] assert set(files) == {"/pr/overview.md", "/pr/diff.patch", "/pr/findings.md"} assert params["config"]["configurable"]["chat_head_sha"] == "abc123def456" assert {"chat_head_sha": "abc123def456"} in captured["updated"] @pytest.mark.asyncio async def test_enrich_chat_command_keeps_context_when_reseed_fails(monkeypatch) -> None: # Existing chat whose head moved, but loading the fresh context fails: the # command must keep answering from the last seeded context instead of erroring. captured = _patch_enrich_deps( monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "old-stale-sha"}, current_head="new-head-sha", ) async def failing_build(*args, **kwargs): raise HTTPException(404, "review not found") monkeypatch.setattr(review_chat_api, "_build_pr_context", failing_build) command = {"method": "run.start", "params": {"input": {"messages": []}}} enriched = await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1", thread_metadata={"kind": "review_chat", "chat_head_sha": "old-stale-sha"}, ) params = enriched["params"] assert "files" not in params["input"] # no reseed assert params["config"]["configurable"]["chat_head_sha"] == "old-stale-sha" assert params["assistant_id"] == "chat" assert captured["updated"] == [] # head metadata not advanced on failure @pytest.mark.asyncio async def test_enrich_chat_command_surfaces_reseed_failure_on_create(monkeypatch) -> None: # A brand-new chat has no prior context to fall back to, so a seeding failure # must surface rather than silently produce an empty conversation. _patch_enrich_deps(monkeypatch, metadata=None) async def failing_build(*args, **kwargs): raise HTTPException(404, "review not found") monkeypatch.setattr(review_chat_api, "_build_pr_context", failing_build) command = {"method": "run.start", "params": {"input": {"messages": []}}} with pytest.raises(HTTPException): await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1" ) @pytest.mark.asyncio async def test_enrich_chat_command_uses_passed_metadata_without_refetch(monkeypatch) -> None: # When the caller supplies the already-fetched metadata, enrichment must not # issue a second thread read on the hot path. captured = _patch_enrich_deps( monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "abc123def456"} ) async def boom(thread_id: str) -> None: raise AssertionError("metadata was supplied; must not refetch the thread") monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", boom) command = {"method": "run.start", "params": {"input": {"messages": []}}} enriched = await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1", thread_metadata={"kind": "review_chat", "chat_head_sha": "abc123def456"}, ) assert enriched["params"]["config"]["configurable"]["chat_head_sha"] == "abc123def456" assert "files" not in enriched["params"]["input"] assert captured.get("head_calls") == 1 # lightweight head lookup, no full get_review @pytest.mark.asyncio async def test_enrich_chat_command_ignores_non_run_start(monkeypatch) -> None: _patch_enrich_deps(monkeypatch, metadata=None) command = {"method": "something.else", "params": {}} enriched = await review_chat_api._enrich_chat_command( command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1" ) assert enriched == command assert "assistant_id" not in enriched["params"] @pytest.mark.asyncio async def test_proxy_state_normalizes_missing_thread(monkeypatch) -> None: async def fake_passthrough(method, thread_id, suffix, body, content_type): return 404, b"not found", "text/plain" async def no_thread(thread_id: str) -> None: return None monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough) monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", no_thread) status, content, media_type = await review_chat_api.proxy_review_chat_state( "acme", "repo", 7, "octocat", "ct-1" ) assert status == 200 assert b'"next": []' in content @pytest.mark.asyncio async def test_proxy_history_normalizes_missing_thread(monkeypatch) -> None: async def fake_passthrough(method, thread_id, suffix, body, content_type): return 404, b"not found", "text/plain" async def no_thread(thread_id: str) -> None: return None monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough) monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", no_thread) status, content, _ = await review_chat_api.proxy_review_chat_history( "acme", "repo", 7, "octocat", "ct-1", b"{}" ) assert status == 200 assert content == b"[]" @pytest.mark.asyncio async def test_proxy_state_rejects_foreign_thread(monkeypatch) -> None: async def other_owner(thread_id: str) -> dict[str, Any]: return { "kind": "review_chat", "github_login": "hubot", "repo_owner": "acme", "repo_name": "repo", "pr_number": 7, } async def fake_passthrough(*args, **kwargs): raise AssertionError("must not proxy a thread the caller doesn't own") monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", other_owner) monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough) with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404) await review_chat_api.proxy_review_chat_state("acme", "repo", 7, "octocat", "ct-1") # --- graph factory guard ----------------------------------------------------- def test_get_chat_agent_returns_trivial_when_not_for_execution() -> None: from agent.chat import get_chat_agent graph = asyncio.run(get_chat_agent({"configurable": {"thread_id": None}})) assert graph is not None