|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import re |
| 4 | +from pathlib import Path |
| 5 | +from typing import get_args |
| 6 | + |
| 7 | +from fastapi.testclient import TestClient |
| 8 | +from pytest import MonkeyPatch |
| 9 | + |
| 10 | +from ailss_api.main import create_app |
| 11 | +from ailss_api.models import FailureCode |
| 12 | +from tests.helpers import ( |
| 13 | + DEFAULT_AGENT_INPUT, |
| 14 | + DEFAULT_RETRIEVE_QUERY, |
| 15 | + build_settings_with_seed_data, |
| 16 | + set_fake_embedding, |
| 17 | +) |
| 18 | + |
| 19 | +PARITY_DOC_PATH = ( |
| 20 | + Path(__file__).resolve().parents[3] / "docs" / "architecture" / "python-mcp-parity.md" |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +def _extract_bulleted_code_items(markdown: str, heading: str) -> list[str]: |
| 25 | + lines = markdown.splitlines() |
| 26 | + heading_index = next( |
| 27 | + (index for index, line in enumerate(lines) if line.strip() == heading), |
| 28 | + -1, |
| 29 | + ) |
| 30 | + if heading_index < 0: |
| 31 | + return [] |
| 32 | + |
| 33 | + values: list[str] = [] |
| 34 | + for line in lines[heading_index + 1 :]: |
| 35 | + stripped = line.strip() |
| 36 | + if not stripped: |
| 37 | + if values: |
| 38 | + break |
| 39 | + continue |
| 40 | + if stripped.startswith("#"): |
| 41 | + break |
| 42 | + |
| 43 | + match = re.match(r"^- `([^`]+)`$", stripped) |
| 44 | + if match is None: |
| 45 | + continue |
| 46 | + values.append(match.group(1)) |
| 47 | + return values |
| 48 | + |
| 49 | + |
| 50 | +def test_parity_doc_lists_current_agent_failure_codes() -> None: |
| 51 | + doc = PARITY_DOC_PATH.read_text(encoding="utf-8") |
| 52 | + |
| 53 | + documented = sorted( |
| 54 | + _extract_bulleted_code_items( |
| 55 | + doc, |
| 56 | + "## Required failure codes for Python-covered agent flows", |
| 57 | + ) |
| 58 | + ) |
| 59 | + actual = sorted(get_args(FailureCode)) |
| 60 | + |
| 61 | + assert documented == actual |
| 62 | + |
| 63 | + |
| 64 | +def test_retrieve_parity_respects_combined_scope_filters( |
| 65 | + tmp_path: Path, monkeypatch: MonkeyPatch |
| 66 | +) -> None: |
| 67 | + settings = build_settings_with_seed_data(tmp_path) |
| 68 | + set_fake_embedding(monkeypatch) |
| 69 | + client = TestClient(create_app(settings)) |
| 70 | + |
| 71 | + response = client.post( |
| 72 | + "/retrieve", |
| 73 | + json={ |
| 74 | + "query": DEFAULT_RETRIEVE_QUERY, |
| 75 | + "top_k": 3, |
| 76 | + "path_prefix": "docs/", |
| 77 | + "tags_any": ["project"], |
| 78 | + "tags_all": ["architecture", "project"], |
| 79 | + }, |
| 80 | + ) |
| 81 | + |
| 82 | + assert response.status_code == 200 |
| 83 | + payload = response.json() |
| 84 | + assert [result["path"] for result in payload["results"]] == ["docs/03-plan.md"] |
| 85 | + assert set(payload["results"][0]["tags"]) == {"architecture", "project"} |
| 86 | + |
| 87 | + |
| 88 | +def test_agent_run_returns_missing_context_when_scoped_filters_remove_candidates( |
| 89 | + tmp_path: Path, monkeypatch: MonkeyPatch |
| 90 | +) -> None: |
| 91 | + settings = build_settings_with_seed_data(tmp_path) |
| 92 | + set_fake_embedding(monkeypatch) |
| 93 | + client = TestClient(create_app(settings)) |
| 94 | + |
| 95 | + response = client.post( |
| 96 | + "/agent/run", |
| 97 | + json={ |
| 98 | + "input": DEFAULT_AGENT_INPUT, |
| 99 | + "context": { |
| 100 | + "path_prefix": "notes/", |
| 101 | + "tags_all": ["architecture"], |
| 102 | + "top_k": 1, |
| 103 | + }, |
| 104 | + }, |
| 105 | + ) |
| 106 | + |
| 107 | + assert response.status_code == 200 |
| 108 | + payload = response.json() |
| 109 | + assert payload["outcome"] == "failed" |
| 110 | + assert payload["failure"]["code"] == "missing_context" |
| 111 | + |
| 112 | + |
| 113 | +def test_agent_run_rejects_write_request_with_apply_true(tmp_path: Path) -> None: |
| 114 | + settings = build_settings_with_seed_data(tmp_path) |
| 115 | + client = TestClient(create_app(settings)) |
| 116 | + |
| 117 | + response = client.post( |
| 118 | + "/agent/run", |
| 119 | + json={ |
| 120 | + "input": "Write the summary into a new note.", |
| 121 | + "requested_write_action": "capture_note", |
| 122 | + "apply": True, |
| 123 | + }, |
| 124 | + ) |
| 125 | + |
| 126 | + assert response.status_code == 200 |
| 127 | + payload = response.json() |
| 128 | + assert payload["outcome"] == "failed" |
| 129 | + assert payload["failure"]["code"] == "write_not_allowed" |
| 130 | + assert payload["write_actions"] == [ |
| 131 | + { |
| 132 | + "action": "capture_note", |
| 133 | + "allowed": False, |
| 134 | + "reason": "write_not_allowed", |
| 135 | + } |
| 136 | + ] |
0 commit comments