ninjaeval-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ninjaeval_mcp-0.1.0/LICENSE +21 -0
- ninjaeval_mcp-0.1.0/PKG-INFO +71 -0
- ninjaeval_mcp-0.1.0/README.md +58 -0
- ninjaeval_mcp-0.1.0/pyproject.toml +22 -0
- ninjaeval_mcp-0.1.0/setup.cfg +4 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp/__init__.py +2 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp/api.py +234 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp/server.py +116 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp.egg-info/PKG-INFO +71 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp.egg-info/SOURCES.txt +13 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp.egg-info/dependency_links.txt +1 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp.egg-info/entry_points.txt +2 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp.egg-info/requires.txt +1 -0
- ninjaeval_mcp-0.1.0/src/ninjaeval_mcp.egg-info/top_level.txt +1 -0
- ninjaeval_mcp-0.1.0/tests/test_mcp_api.py +135 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NinjaEval
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ninjaeval-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NinjaEval as an MCP server: a coding agent tests a prompt, model or tool change and reads the verdict and the diagnosis before calling it done.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://ninjaeval.com
|
|
7
|
+
Project-URL: Documentation, https://ninjaeval.com/docs/developer-guide.html
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: mcp<3,>=1.2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# NinjaEval MCP server
|
|
15
|
+
|
|
16
|
+
Lets your coding agent (Claude Code, Cursor, Copilot, any MCP client) test an AI change with
|
|
17
|
+
[NinjaEval](https://ninjaeval.com) before it calls the change done.
|
|
18
|
+
|
|
19
|
+
The agent edits your bot's system prompt, model or tools, then calls `test_change`. NinjaEval replays your test cases
|
|
20
|
+
on production and on the change and grades every answer. It returns **ready** or **blocked**, with the cause of each
|
|
21
|
+
regression and a fix that was re-run to check it. The agent applies the fix and tests again. Shipping stays with you:
|
|
22
|
+
the server can't promote a version to production.
|
|
23
|
+
|
|
24
|
+
## Set up
|
|
25
|
+
|
|
26
|
+
1. In NinjaEval, open **⚙ Workspace & API keys** and create a key for the agent (e.g. `coding-agent`).
|
|
27
|
+
2. Add the server to your MCP client. For Claude Code:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude mcp add ninjaeval --env NINJAEVAL_API_KEY=efk_... -- uvx ninjaeval-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Any other client (`mcpServers` in its JSON config):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "mcpServers": { "ninjaeval": { "command": "uvx", "args": ["ninjaeval-mcp"],
|
|
37
|
+
"env": { "NINJAEVAL_API_KEY": "efk_..." } } } }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or `pip install ninjaeval-mcp` and run `ninjaeval-mcp`.
|
|
41
|
+
|
|
42
|
+
It runs over stdio on your machine and opens no network port. It talks only to `api.ninjaeval.com`.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | What it does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `whoami` | The workspace, the key's role, today's limits |
|
|
49
|
+
| `list_projects` | Your projects (AI apps under test) |
|
|
50
|
+
| `get_project` | Production's provider, model, system prompt and tools; the gate; suites; recent change tests |
|
|
51
|
+
| `test_change` | Tests a change against production: only the fields you pass change (`system_prompt`, `model`, `provider`, `params`, `tools`, `code_sha`, `note`, `stage`, `trials`). Waits for the verdict and the diagnoses |
|
|
52
|
+
| `get_change_test` | A test's verdict, rules, regressions with cause / explanation / fix (`prompt_edits`) / whether the fix was verified, cases still failing, and the next step |
|
|
53
|
+
| `diagnose_cases` | Diagnoses failing cases that weren't diagnosed automatically |
|
|
54
|
+
| `get_report` | The full report as Markdown, e.g. for a PR description |
|
|
55
|
+
|
|
56
|
+
Each response has a `next` field telling the agent what to do. When the cause is `TEST`, the test itself is wrong, so
|
|
57
|
+
the agent should tell you rather than bend the product to fit it.
|
|
58
|
+
|
|
59
|
+
## Settings
|
|
60
|
+
|
|
61
|
+
| Variable | Default | |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `NINJAEVAL_API_KEY` | | Workspace key (`efk_…`) |
|
|
64
|
+
| `NINJAEVAL_API_KEY_FILE` | | Read the key from a file instead |
|
|
65
|
+
| `NINJAEVAL_API_URL` | `https://api.ninjaeval.com/api/v1/eng` | |
|
|
66
|
+
| `NINJAEVAL_APP_URL` | `https://ninjaeval.com` | Used for the links in responses |
|
|
67
|
+
| `NINJAEVAL_MCP_MAX_TESTS` | `10` | Change tests one session may start, so an agent can't loop on fixes indefinitely |
|
|
68
|
+
|
|
69
|
+
Each change test runs your real app and an AI judge, which takes minutes and counts toward the workspace's daily
|
|
70
|
+
limits. Endpoint projects call your service with the system prompt NinjaEval sends. If your service ignores it, test
|
|
71
|
+
code changes by deploying them where the endpoint runs and passing `code_sha`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# NinjaEval MCP server
|
|
2
|
+
|
|
3
|
+
Lets your coding agent (Claude Code, Cursor, Copilot, any MCP client) test an AI change with
|
|
4
|
+
[NinjaEval](https://ninjaeval.com) before it calls the change done.
|
|
5
|
+
|
|
6
|
+
The agent edits your bot's system prompt, model or tools, then calls `test_change`. NinjaEval replays your test cases
|
|
7
|
+
on production and on the change and grades every answer. It returns **ready** or **blocked**, with the cause of each
|
|
8
|
+
regression and a fix that was re-run to check it. The agent applies the fix and tests again. Shipping stays with you:
|
|
9
|
+
the server can't promote a version to production.
|
|
10
|
+
|
|
11
|
+
## Set up
|
|
12
|
+
|
|
13
|
+
1. In NinjaEval, open **⚙ Workspace & API keys** and create a key for the agent (e.g. `coding-agent`).
|
|
14
|
+
2. Add the server to your MCP client. For Claude Code:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
claude mcp add ninjaeval --env NINJAEVAL_API_KEY=efk_... -- uvx ninjaeval-mcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Any other client (`mcpServers` in its JSON config):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{ "mcpServers": { "ninjaeval": { "command": "uvx", "args": ["ninjaeval-mcp"],
|
|
24
|
+
"env": { "NINJAEVAL_API_KEY": "efk_..." } } } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or `pip install ninjaeval-mcp` and run `ninjaeval-mcp`.
|
|
28
|
+
|
|
29
|
+
It runs over stdio on your machine and opens no network port. It talks only to `api.ninjaeval.com`.
|
|
30
|
+
|
|
31
|
+
## Tools
|
|
32
|
+
|
|
33
|
+
| Tool | What it does |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `whoami` | The workspace, the key's role, today's limits |
|
|
36
|
+
| `list_projects` | Your projects (AI apps under test) |
|
|
37
|
+
| `get_project` | Production's provider, model, system prompt and tools; the gate; suites; recent change tests |
|
|
38
|
+
| `test_change` | Tests a change against production: only the fields you pass change (`system_prompt`, `model`, `provider`, `params`, `tools`, `code_sha`, `note`, `stage`, `trials`). Waits for the verdict and the diagnoses |
|
|
39
|
+
| `get_change_test` | A test's verdict, rules, regressions with cause / explanation / fix (`prompt_edits`) / whether the fix was verified, cases still failing, and the next step |
|
|
40
|
+
| `diagnose_cases` | Diagnoses failing cases that weren't diagnosed automatically |
|
|
41
|
+
| `get_report` | The full report as Markdown, e.g. for a PR description |
|
|
42
|
+
|
|
43
|
+
Each response has a `next` field telling the agent what to do. When the cause is `TEST`, the test itself is wrong, so
|
|
44
|
+
the agent should tell you rather than bend the product to fit it.
|
|
45
|
+
|
|
46
|
+
## Settings
|
|
47
|
+
|
|
48
|
+
| Variable | Default | |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `NINJAEVAL_API_KEY` | | Workspace key (`efk_…`) |
|
|
51
|
+
| `NINJAEVAL_API_KEY_FILE` | | Read the key from a file instead |
|
|
52
|
+
| `NINJAEVAL_API_URL` | `https://api.ninjaeval.com/api/v1/eng` | |
|
|
53
|
+
| `NINJAEVAL_APP_URL` | `https://ninjaeval.com` | Used for the links in responses |
|
|
54
|
+
| `NINJAEVAL_MCP_MAX_TESTS` | `10` | Change tests one session may start, so an agent can't loop on fixes indefinitely |
|
|
55
|
+
|
|
56
|
+
Each change test runs your real app and an AI judge, which takes minutes and counts toward the workspace's daily
|
|
57
|
+
limits. Endpoint projects call your service with the system prompt NinjaEval sends. If your service ignores it, test
|
|
58
|
+
code changes by deploying them where the endpoint runs and passing `code_sha`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ninjaeval-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "NinjaEval as an MCP server: a coding agent tests a prompt, model or tool change and reads the verdict and the diagnosis before calling it done."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["mcp>=1.2,<3"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
ninjaeval-mcp = "ninjaeval_mcp.server:main"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://ninjaeval.com"
|
|
19
|
+
Documentation = "https://ninjaeval.com/docs/developer-guide.html"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""NinjaEval API client and the compact summaries the MCP tools return. Standard library only, so it can be tested
|
|
2
|
+
without the MCP package."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
DEFAULT_API = "https://api.ninjaeval.com/api/v1/eng"
|
|
12
|
+
DEFAULT_APP = "https://ninjaeval.com"
|
|
13
|
+
PASS = "PASS"
|
|
14
|
+
FAILING = ("regressed", "errored")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _tls():
|
|
18
|
+
"""certifi's CA bundle when installed (mcp brings it via httpx): python.org builds on macOS have none by default."""
|
|
19
|
+
try:
|
|
20
|
+
import certifi
|
|
21
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
22
|
+
except ImportError:
|
|
23
|
+
return ssl.create_default_context()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NinjaEvalError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _key_from_env():
|
|
31
|
+
key = os.environ.get("NINJAEVAL_API_KEY", "").strip()
|
|
32
|
+
path = os.environ.get("NINJAEVAL_API_KEY_FILE", "").strip()
|
|
33
|
+
if not key and path:
|
|
34
|
+
with open(os.path.expanduser(path)) as f:
|
|
35
|
+
key = f.read().strip()
|
|
36
|
+
return key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Client:
|
|
40
|
+
"""One workspace, through its API key (`efk_…`, from ⚙ Workspace & API keys)."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, key=None, api=None, app=None, sleep=time.sleep, clock=time.monotonic):
|
|
43
|
+
self.key = key if key is not None else _key_from_env()
|
|
44
|
+
self.api = (api or os.environ.get("NINJAEVAL_API_URL") or DEFAULT_API).rstrip("/")
|
|
45
|
+
self.app = (app or os.environ.get("NINJAEVAL_APP_URL") or DEFAULT_APP).rstrip("/")
|
|
46
|
+
self.sleep, self.clock = sleep, clock
|
|
47
|
+
|
|
48
|
+
# ── HTTP ──
|
|
49
|
+
def call(self, method, path, body=None, text=False):
|
|
50
|
+
if not self.key:
|
|
51
|
+
raise NinjaEvalError("No API key: set NINJAEVAL_API_KEY (or NINJAEVAL_API_KEY_FILE) to a workspace key "
|
|
52
|
+
"from NinjaEval → ⚙ Workspace & API keys.")
|
|
53
|
+
req = urllib.request.Request(self.api + path, method=method,
|
|
54
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
55
|
+
headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json",
|
|
56
|
+
"User-Agent": "ninjaeval-mcp"})
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(req, timeout=60, context=_tls()) as r:
|
|
59
|
+
raw = r.read().decode()
|
|
60
|
+
except urllib.error.HTTPError as e:
|
|
61
|
+
detail = e.read().decode(errors="replace")
|
|
62
|
+
try:
|
|
63
|
+
detail = json.loads(detail).get("detail", detail)
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
raise NinjaEvalError(f"NinjaEval API {method} {path}: {e.code} {detail}") from None
|
|
67
|
+
except urllib.error.URLError as e:
|
|
68
|
+
raise NinjaEvalError(f"Can't reach NinjaEval at {self.api}: {e.reason}") from None
|
|
69
|
+
return raw if text else (json.loads(raw) if raw else None)
|
|
70
|
+
|
|
71
|
+
# ── reads ──
|
|
72
|
+
def me(self):
|
|
73
|
+
m = self.call("GET", "/me")
|
|
74
|
+
return {k: m.get(k) for k in ("workspace", "role", "can_run", "can_manage", "workspace_limits") if k in m}
|
|
75
|
+
|
|
76
|
+
def projects(self):
|
|
77
|
+
return [{"project_id": p["project_id"], "name": p.get("name"), "run_mode": p.get("run_mode"),
|
|
78
|
+
"description": p.get("description") or ""} for p in self.call("GET", "/projects")["projects"]]
|
|
79
|
+
|
|
80
|
+
def project(self, project_id):
|
|
81
|
+
v = self.call("GET", f"/projects/{q(project_id)}")
|
|
82
|
+
prod = v.get("production") or {}
|
|
83
|
+
return {
|
|
84
|
+
"project_id": project_id, "name": v["project"].get("name"), "run_mode": v["project"].get("run_mode"),
|
|
85
|
+
"gate": v["project"].get("gate"),
|
|
86
|
+
"production": {k: prod.get(k) for k in ("label", "provider", "model", "params", "system_prompt", "tools", "code_sha", "note")}
|
|
87
|
+
if prod else None,
|
|
88
|
+
"suites": [{"dataset_id": d["dataset_id"], "name": d.get("name"), "cases": d.get("item_count")} for d in v.get("datasets") or []],
|
|
89
|
+
"ready_to_test": all((v.get("setup") or {}).values()),
|
|
90
|
+
"setup": v.get("setup"),
|
|
91
|
+
"recent_change_tests": [{"test_id": t["test_id"], "candidate": t.get("candidate_label"), "baseline": t.get("baseline_label"),
|
|
92
|
+
"status": t.get("status"), "complete": t.get("complete"), "created_at": t.get("created_at"),
|
|
93
|
+
"url": self.test_url(t["test_id"])} for t in (v.get("change_tests") or [])[:5]],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def test(self, test_id):
|
|
97
|
+
return self.call("GET", f"/change-tests/{q(test_id)}")
|
|
98
|
+
|
|
99
|
+
def report(self, test_id):
|
|
100
|
+
return self.call("GET", f"/change-tests/{q(test_id)}/report.md", text=True)
|
|
101
|
+
|
|
102
|
+
def test_url(self, test_id):
|
|
103
|
+
return f"{self.app}/#/change/{test_id}"
|
|
104
|
+
|
|
105
|
+
# ── writes ──
|
|
106
|
+
def start_change_test(self, project_id, **changes):
|
|
107
|
+
body = {k: v for k, v in changes.items() if v is not None}
|
|
108
|
+
return self.call("POST", f"/projects/{q(project_id)}/test-change", body)
|
|
109
|
+
|
|
110
|
+
def analyze(self, test_id, item_ids, runs=3):
|
|
111
|
+
return self.call("POST", f"/change-tests/{q(test_id)}/analyze", {"item_ids": list(item_ids), "runs": runs})
|
|
112
|
+
|
|
113
|
+
# ── waiting ──
|
|
114
|
+
def wait(self, test_id, timeout=600, poll=5, diag_grace=120, item_ids=None):
|
|
115
|
+
"""Poll until the runs finish and the diagnoses of the failing cases (or `item_ids`) settle, or the timeout.
|
|
116
|
+
A diagnosis that hasn't started `diag_grace` seconds after the runs finish isn't waited for."""
|
|
117
|
+
start = self.clock()
|
|
118
|
+
finished_at = None
|
|
119
|
+
while True:
|
|
120
|
+
t = self.test(test_id)
|
|
121
|
+
if runs_done(t):
|
|
122
|
+
finished_at = finished_at if finished_at is not None else self.clock()
|
|
123
|
+
waiting = diagnoses_pending(t, item_ids)
|
|
124
|
+
if not waiting or (not any(waiting.values()) and self.clock() - finished_at >= diag_grace):
|
|
125
|
+
return t
|
|
126
|
+
if self.clock() - start >= timeout:
|
|
127
|
+
return t
|
|
128
|
+
self.sleep(poll)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def q(s):
|
|
132
|
+
return urllib.parse.quote(str(s), safe="")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def runs_done(t):
|
|
136
|
+
p = t.get("progress") or {}
|
|
137
|
+
return bool(p.get("total")) and p.get("done") == p.get("total") and (t.get("gate") or {}).get("verdict") != "pending"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def diagnoses_pending(t, item_ids=None):
|
|
141
|
+
"""{item_id: started?} for the cases whose diagnosis is still to come."""
|
|
142
|
+
want = set(item_ids) if item_ids else None
|
|
143
|
+
out = {}
|
|
144
|
+
for r in t.get("rows") or []:
|
|
145
|
+
if want is not None:
|
|
146
|
+
if r["item_id"] not in want:
|
|
147
|
+
continue
|
|
148
|
+
elif r.get("kind") != "regressed":
|
|
149
|
+
continue
|
|
150
|
+
rca = r.get("rca") or {}
|
|
151
|
+
if rca.get("status") not in ("done", "error"):
|
|
152
|
+
out[r["item_id"]] = bool(rca)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _clip(s, n=1500):
|
|
157
|
+
s = s or ""
|
|
158
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def diagnosis(rca):
|
|
162
|
+
if not rca:
|
|
163
|
+
return None
|
|
164
|
+
if rca.get("status") != "done":
|
|
165
|
+
return {"status": rca.get("status") or "queued", **({"error": _clip(rca.get("error"), 300)} if rca.get("error") else {})}
|
|
166
|
+
res = rca.get("result") or {}
|
|
167
|
+
d, fix, ver = res.get("diagnosis") or {}, res.get("fix") or {}, res.get("verification") or None
|
|
168
|
+
out = {"status": "done", "cause": d.get("cause"), "kind": d.get("kind"), "explanation": _clip(d.get("explanation")),
|
|
169
|
+
"agreement": res.get("agreement"), "needs_review": bool(res.get("needs_review")),
|
|
170
|
+
"fix": {k: fix[k] for k in ("type", "summary", "prompt_edits", "test_fix", "risky") if fix.get(k)}}
|
|
171
|
+
if ver:
|
|
172
|
+
out["fix_verified"] = {"status": ver.get("status"), "note": _clip(ver.get("note"), 400)}
|
|
173
|
+
prior = res.get("prior") or {}
|
|
174
|
+
if prior.get("flagged"):
|
|
175
|
+
out["prior_disagrees"] = {"cause": prior.get("cause"), "note": "a confirmed past diagnosis of a similar failure says otherwise; check before acting"}
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _side(s):
|
|
180
|
+
return {"label": s.get("label"), "score": s.get("score")} if s else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def summary(t, url):
|
|
184
|
+
"""What a coding agent needs from a change test: the verdict, why, and what to do next."""
|
|
185
|
+
gate, test = t.get("gate") or {}, t.get("test") or {}
|
|
186
|
+
rows = t.get("rows") or []
|
|
187
|
+
done = runs_done(t)
|
|
188
|
+
bs, cs = t.get("baseline_summary") or {}, t.get("candidate_summary") or {}
|
|
189
|
+
num = lambda s: {k: s.get(k) for k in ("passed", "cases", "avg_score") if k in s}
|
|
190
|
+
failing = [r for r in rows if r.get("kind") in FAILING]
|
|
191
|
+
still_failing = [r for r in rows if r.get("kind") in ("same", "worse") and (r.get("candidate") or {}).get("label") not in (None, PASS)]
|
|
192
|
+
out = {
|
|
193
|
+
"test_id": test.get("test_id"), "project_id": test.get("project_id"), "url": url,
|
|
194
|
+
"status": "done" if done else "running",
|
|
195
|
+
"progress": f"{(t.get('progress') or {}).get('done', 0)}/{(t.get('progress') or {}).get('total', 0)} answers",
|
|
196
|
+
"verdict": gate.get("verdict"),
|
|
197
|
+
**({"would_be": gate["would_be"]} if gate.get("would_be") else {}),
|
|
198
|
+
**({"stage": gate["stage"]} if gate.get("stage") else {}),
|
|
199
|
+
"rules": [{"rule": r.get("rule"), "passed": r.get("passed"), "detail": r.get("detail")} for r in gate.get("rules") or []],
|
|
200
|
+
"counts": t.get("counts"),
|
|
201
|
+
"baseline": {"label": (t.get("baseline") or {}).get("label"), **num(bs)},
|
|
202
|
+
"candidate": {"label": (t.get("candidate") or {}).get("label"), "note": (t.get("candidate") or {}).get("note"), **num(cs)},
|
|
203
|
+
"regressions": [{"item_id": r["item_id"], "title": r.get("title"), "kind": r["kind"], "tags": r.get("tags") or [],
|
|
204
|
+
"before": _side(r.get("baseline")), "after": _side(r.get("candidate")),
|
|
205
|
+
"diagnosis": diagnosis(r.get("rca"))} for r in failing],
|
|
206
|
+
"still_failing": [{"item_id": r["item_id"], "title": r.get("title"), "kind": r["kind"], "after": _side(r.get("candidate")),
|
|
207
|
+
**({"new_failures": r["new_failures"]} if r.get("new_failures") else {}),
|
|
208
|
+
**({"diagnosis": diagnosis(r["rca"])} if r.get("rca") else {})} for r in still_failing],
|
|
209
|
+
}
|
|
210
|
+
out["next"] = next_step(out)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def next_step(s):
|
|
215
|
+
if s["status"] != "done":
|
|
216
|
+
return f"Still running ({s['progress']}). Call get_change_test with test_id {s['test_id']} and wait=true."
|
|
217
|
+
v = s["verdict"]
|
|
218
|
+
if v == "ready":
|
|
219
|
+
return ("The gate passes. Shipping (promoting to production) is a person's decision: share the url, or let CI "
|
|
220
|
+
"run the same test on the pull request.")
|
|
221
|
+
diags = [r["diagnosis"] for r in s["regressions"] if r.get("diagnosis")]
|
|
222
|
+
if any(d.get("status") != "done" for d in diags) or len(diags) < len(s["regressions"]):
|
|
223
|
+
return "Diagnoses are still running; call get_change_test with wait=true to read the causes and fixes."
|
|
224
|
+
if any(d.get("cause") == "TEST" for d in diags):
|
|
225
|
+
hint = " Where the cause is TEST, the test itself is wrong: tell the user rather than changing the product to fit it."
|
|
226
|
+
else:
|
|
227
|
+
hint = ""
|
|
228
|
+
if any((d.get("fix") or {}).get("prompt_edits") for d in diags):
|
|
229
|
+
return ("Blocked. Apply each regression's prompt_edits (find → replace) to the system prompt, or write a better fix, "
|
|
230
|
+
"then call test_change again. Don't remove or weaken test cases to pass the gate." + hint)
|
|
231
|
+
if v in ("blocked", "partial"):
|
|
232
|
+
return ("Blocked. Read each regression's diagnosis and fix, change the prompt, model, tools or code, and call "
|
|
233
|
+
"test_change again. Don't remove or weaken test cases to pass the gate." + hint)
|
|
234
|
+
return f"Verdict: {v}."
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""NinjaEval MCP server: a coding agent tests an AI change (prompt, model, tools, code) before calling it done.
|
|
2
|
+
|
|
3
|
+
Runs over stdio; it opens no network port. It acts as one workspace, through that workspace's API key.
|
|
4
|
+
It can start change tests and diagnoses but can't ship: promoting a version to production stays with a person.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
try: # mcp 2.x
|
|
10
|
+
from mcp.server.mcpserver import MCPServer as _Server
|
|
11
|
+
except ImportError: # mcp 1.x
|
|
12
|
+
from mcp.server.fastmcp import FastMCP as _Server
|
|
13
|
+
|
|
14
|
+
from .api import Client, NinjaEvalError, summary
|
|
15
|
+
|
|
16
|
+
mcp = _Server("ninjaeval", instructions=(
|
|
17
|
+
"NinjaEval is a release gate for AI changes. After you change an AI app's system prompt, model, tools or agent code, "
|
|
18
|
+
"call test_change: it replays the project's test cases on production and on your change, grades every answer, and "
|
|
19
|
+
"returns a ready / blocked verdict with the cause and a fix for each regression. Don't call a prompt or model change "
|
|
20
|
+
"done while the verdict is blocked. Each test runs the real app and an AI judge, so it costs money and takes minutes: "
|
|
21
|
+
"test once per meaningful change, not after every keystroke. Never delete or weaken test cases to pass the gate."))
|
|
22
|
+
|
|
23
|
+
_client = None
|
|
24
|
+
_started = 0
|
|
25
|
+
MAX_TESTS = int(os.environ.get("NINJAEVAL_MCP_MAX_TESTS", "10")) # per server session, against an agent looping on fixes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def client():
|
|
29
|
+
global _client
|
|
30
|
+
if _client is None:
|
|
31
|
+
_client = Client()
|
|
32
|
+
return _client
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def whoami() -> dict:
|
|
37
|
+
"""The workspace this server acts in, and what its API key may do."""
|
|
38
|
+
return client().me()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def list_projects() -> list:
|
|
43
|
+
"""The workspace's projects (AI apps under test). Use project_id in the other tools."""
|
|
44
|
+
return client().projects()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
def get_project(project_id: str) -> dict:
|
|
49
|
+
"""A project's production version (provider, model, system prompt, tools), release gate, test suites and recent
|
|
50
|
+
change tests. Read this before test_change to see what production is."""
|
|
51
|
+
return client().project(project_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def test_change(project_id: str, system_prompt: Optional[str] = None, model: Optional[str] = None,
|
|
56
|
+
provider: Optional[str] = None, params: Optional[dict] = None, tools: Optional[List[dict]] = None,
|
|
57
|
+
note: str = "", code_sha: str = "", stage: Optional[str] = None, trials: int = 1,
|
|
58
|
+
wait: bool = True, timeout_seconds: int = 600) -> dict:
|
|
59
|
+
"""Test a change against production. Copies production and applies only the fields you pass: system_prompt (the
|
|
60
|
+
full new prompt), model, provider, params, tools ([{name, description, parameters}]). For an endpoint project whose
|
|
61
|
+
change is in code, deploy it where the project's endpoint runs it and pass code_sha. note says what changed and why.
|
|
62
|
+
stage runs only that stage's cases (e.g. "smoke"; quick, can't ship). trials > 1 runs each case several times.
|
|
63
|
+
|
|
64
|
+
With wait=true (default) it returns when the verdict and the regressions' diagnoses are in, or at timeout_seconds
|
|
65
|
+
(then call get_change_test). Returns verdict (ready / blocked / partial / pending), the gate's rules, each regression
|
|
66
|
+
with its diagnosis (cause PROMPT / MODEL / TOOL / TEST, explanation, fix with prompt_edits), a url and a next step."""
|
|
67
|
+
global _started
|
|
68
|
+
if _started >= MAX_TESTS:
|
|
69
|
+
raise NinjaEvalError(f"This session already started {MAX_TESTS} change tests (NINJAEVAL_MCP_MAX_TESTS). "
|
|
70
|
+
"Stop and show the user the results so far.")
|
|
71
|
+
c = client()
|
|
72
|
+
t = c.start_change_test(project_id, system_prompt=system_prompt, model=model, provider=provider, params=params,
|
|
73
|
+
tools=tools, note=note or "Change from a coding agent (MCP)", code_sha=code_sha, stage=stage,
|
|
74
|
+
trials=max(1, min(int(trials), 5)))
|
|
75
|
+
_started += 1
|
|
76
|
+
test_id = t["test_id"]
|
|
77
|
+
full = c.wait(test_id, timeout=_timeout(timeout_seconds)) if wait else c.test(test_id)
|
|
78
|
+
return summary(full, c.test_url(test_id))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@mcp.tool()
|
|
82
|
+
def get_change_test(test_id: str, wait: bool = False, timeout_seconds: int = 600) -> dict:
|
|
83
|
+
"""A change test's verdict, rules, regressions with their diagnoses and fixes, and the next step. With wait=true,
|
|
84
|
+
first waits for the runs and the diagnoses to finish (up to timeout_seconds)."""
|
|
85
|
+
c = client()
|
|
86
|
+
full = c.wait(test_id, timeout=_timeout(timeout_seconds)) if wait else c.test(test_id)
|
|
87
|
+
return summary(full, c.test_url(test_id))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@mcp.tool()
|
|
91
|
+
def diagnose_cases(test_id: str, item_ids: List[str], wait: bool = True, timeout_seconds: int = 300) -> dict:
|
|
92
|
+
"""Diagnose failing cases that weren't diagnosed automatically (regressions are). item_ids come from
|
|
93
|
+
get_change_test's still_failing. Each diagnosis runs 3 times and re-runs its fix to check it."""
|
|
94
|
+
c = client()
|
|
95
|
+
c.analyze(test_id, item_ids)
|
|
96
|
+
full = c.wait(test_id, timeout=_timeout(timeout_seconds), item_ids=item_ids) if wait else c.test(test_id)
|
|
97
|
+
return summary(full, c.test_url(test_id))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
def get_report(test_id: str) -> str:
|
|
102
|
+
"""The change test's full report as Markdown: both versions, every case result, the gate and the diagnoses.
|
|
103
|
+
Good for a pull request description."""
|
|
104
|
+
return client().report(test_id)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _timeout(s):
|
|
108
|
+
return max(10, min(int(s), 1800))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main():
|
|
112
|
+
mcp.run("stdio") # stdio only: no network listener
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ninjaeval-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NinjaEval as an MCP server: a coding agent tests a prompt, model or tool change and reads the verdict and the diagnosis before calling it done.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://ninjaeval.com
|
|
7
|
+
Project-URL: Documentation, https://ninjaeval.com/docs/developer-guide.html
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: mcp<3,>=1.2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# NinjaEval MCP server
|
|
15
|
+
|
|
16
|
+
Lets your coding agent (Claude Code, Cursor, Copilot, any MCP client) test an AI change with
|
|
17
|
+
[NinjaEval](https://ninjaeval.com) before it calls the change done.
|
|
18
|
+
|
|
19
|
+
The agent edits your bot's system prompt, model or tools, then calls `test_change`. NinjaEval replays your test cases
|
|
20
|
+
on production and on the change and grades every answer. It returns **ready** or **blocked**, with the cause of each
|
|
21
|
+
regression and a fix that was re-run to check it. The agent applies the fix and tests again. Shipping stays with you:
|
|
22
|
+
the server can't promote a version to production.
|
|
23
|
+
|
|
24
|
+
## Set up
|
|
25
|
+
|
|
26
|
+
1. In NinjaEval, open **⚙ Workspace & API keys** and create a key for the agent (e.g. `coding-agent`).
|
|
27
|
+
2. Add the server to your MCP client. For Claude Code:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude mcp add ninjaeval --env NINJAEVAL_API_KEY=efk_... -- uvx ninjaeval-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Any other client (`mcpServers` in its JSON config):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "mcpServers": { "ninjaeval": { "command": "uvx", "args": ["ninjaeval-mcp"],
|
|
37
|
+
"env": { "NINJAEVAL_API_KEY": "efk_..." } } } }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or `pip install ninjaeval-mcp` and run `ninjaeval-mcp`.
|
|
41
|
+
|
|
42
|
+
It runs over stdio on your machine and opens no network port. It talks only to `api.ninjaeval.com`.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | What it does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `whoami` | The workspace, the key's role, today's limits |
|
|
49
|
+
| `list_projects` | Your projects (AI apps under test) |
|
|
50
|
+
| `get_project` | Production's provider, model, system prompt and tools; the gate; suites; recent change tests |
|
|
51
|
+
| `test_change` | Tests a change against production: only the fields you pass change (`system_prompt`, `model`, `provider`, `params`, `tools`, `code_sha`, `note`, `stage`, `trials`). Waits for the verdict and the diagnoses |
|
|
52
|
+
| `get_change_test` | A test's verdict, rules, regressions with cause / explanation / fix (`prompt_edits`) / whether the fix was verified, cases still failing, and the next step |
|
|
53
|
+
| `diagnose_cases` | Diagnoses failing cases that weren't diagnosed automatically |
|
|
54
|
+
| `get_report` | The full report as Markdown, e.g. for a PR description |
|
|
55
|
+
|
|
56
|
+
Each response has a `next` field telling the agent what to do. When the cause is `TEST`, the test itself is wrong, so
|
|
57
|
+
the agent should tell you rather than bend the product to fit it.
|
|
58
|
+
|
|
59
|
+
## Settings
|
|
60
|
+
|
|
61
|
+
| Variable | Default | |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `NINJAEVAL_API_KEY` | | Workspace key (`efk_…`) |
|
|
64
|
+
| `NINJAEVAL_API_KEY_FILE` | | Read the key from a file instead |
|
|
65
|
+
| `NINJAEVAL_API_URL` | `https://api.ninjaeval.com/api/v1/eng` | |
|
|
66
|
+
| `NINJAEVAL_APP_URL` | `https://ninjaeval.com` | Used for the links in responses |
|
|
67
|
+
| `NINJAEVAL_MCP_MAX_TESTS` | `10` | Change tests one session may start, so an agent can't loop on fixes indefinitely |
|
|
68
|
+
|
|
69
|
+
Each change test runs your real app and an AI judge, which takes minutes and counts toward the workspace's daily
|
|
70
|
+
limits. Endpoint projects call your service with the system prompt NinjaEval sends. If your service ignores it, test
|
|
71
|
+
code changes by deploying them where the endpoint runs and passing `code_sha`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/ninjaeval_mcp/__init__.py
|
|
5
|
+
src/ninjaeval_mcp/api.py
|
|
6
|
+
src/ninjaeval_mcp/server.py
|
|
7
|
+
src/ninjaeval_mcp.egg-info/PKG-INFO
|
|
8
|
+
src/ninjaeval_mcp.egg-info/SOURCES.txt
|
|
9
|
+
src/ninjaeval_mcp.egg-info/dependency_links.txt
|
|
10
|
+
src/ninjaeval_mcp.egg-info/entry_points.txt
|
|
11
|
+
src/ninjaeval_mcp.egg-info/requires.txt
|
|
12
|
+
src/ninjaeval_mcp.egg-info/top_level.txt
|
|
13
|
+
tests/test_mcp_api.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp<3,>=1.2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ninjaeval_mcp
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The MCP server's API client and summaries, against a fake API (no network, no MCP package needed)."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
6
|
+
|
|
7
|
+
import pytest # noqa: E402
|
|
8
|
+
|
|
9
|
+
from ninjaeval_mcp import api # noqa: E402
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def row(item, kind, before="PASS", after="FAIL", rca=None, **kw):
|
|
13
|
+
return {"item_id": item, "title": f"case {item}", "kind": kind, "tags": [],
|
|
14
|
+
"baseline": {"label": before, "score": 1.0}, "candidate": {"label": after, "score": 0.2}, "rca": rca, **kw}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DONE_RCA = {"status": "done", "result": {
|
|
18
|
+
"diagnosis": {"cause": "PROMPT", "kind": "conflicting_instruction", "explanation": "The new line overrides the rule."},
|
|
19
|
+
"agreement": 1.0, "needs_review": False,
|
|
20
|
+
"fix": {"type": "fix_prompt", "summary": "Limit it", "prompt_edits": [{"find": "a", "replace": "b"}]},
|
|
21
|
+
"verification": {"status": "passed", "note": "2/2 re-runs pass"}}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def change_test(rows, done=4, total=4, verdict="blocked"):
|
|
25
|
+
return {"test": {"test_id": "t1", "project_id": "p"}, "progress": {"done": done, "total": total},
|
|
26
|
+
"gate": {"verdict": verdict, "rules": [{"rule": "No case goes from pass → fail", "passed": verdict == "ready", "detail": "#1"}]},
|
|
27
|
+
"counts": {"regressed": 1}, "rows": rows, "baseline": {"label": "v1"}, "candidate": {"label": "v2", "note": "n"},
|
|
28
|
+
"baseline_summary": {"passed": 2, "cases": 2, "avg_score": 1.0}, "candidate_summary": {"passed": 1, "cases": 2, "avg_score": 0.6}}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Fake(api.Client):
|
|
32
|
+
"""Serves a scripted sequence of change-test bodies; time moves on each sleep."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, bodies):
|
|
35
|
+
self.now = 0.0
|
|
36
|
+
super().__init__(key="efk_test", api="https://api.example", app="https://app.example",
|
|
37
|
+
sleep=lambda s: setattr(self, "now", self.now + s), clock=lambda: self.now)
|
|
38
|
+
self.bodies, self.calls = list(bodies), []
|
|
39
|
+
|
|
40
|
+
def call(self, method, path, body=None, text=False):
|
|
41
|
+
self.calls.append((method, path, body))
|
|
42
|
+
if path.startswith("/change-tests/") and method == "GET":
|
|
43
|
+
return self.bodies.pop(0) if len(self.bodies) > 1 else self.bodies[0]
|
|
44
|
+
if path.endswith("/test-change"):
|
|
45
|
+
return {"test_id": "t1"}
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_summary_blocked_with_a_prompt_fix():
|
|
50
|
+
s = api.summary(change_test([row("1", "regressed", rca=DONE_RCA), row("2", "same", after="PASS")]), "u")
|
|
51
|
+
assert s["status"] == "done" and s["verdict"] == "blocked"
|
|
52
|
+
r = s["regressions"][0]
|
|
53
|
+
assert r["diagnosis"]["cause"] == "PROMPT" and r["diagnosis"]["fix"]["prompt_edits"] == [{"find": "a", "replace": "b"}]
|
|
54
|
+
assert r["diagnosis"]["fix_verified"]["status"] == "passed"
|
|
55
|
+
assert "prompt_edits" in s["next"] and "Don't remove or weaken" in s["next"]
|
|
56
|
+
assert s["still_failing"] == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_summary_ready_says_a_person_ships():
|
|
60
|
+
s = api.summary(change_test([row("1", "same", after="PASS")], verdict="ready"), "u")
|
|
61
|
+
assert s["verdict"] == "ready" and "person" in s["next"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_summary_running():
|
|
65
|
+
s = api.summary(change_test([row("1", "pending")], done=1, total=4, verdict="pending"), "u")
|
|
66
|
+
assert s["status"] == "running" and "wait=true" in s["next"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_test_cause_tells_the_user():
|
|
70
|
+
rca = {"status": "done", "result": {"diagnosis": {"cause": "TEST"}, "fix": {"type": "fix_test", "test_fix": {"x": 1}}}}
|
|
71
|
+
s = api.summary(change_test([row("1", "regressed", rca=rca)]), "u")
|
|
72
|
+
assert "the test itself is wrong" in s["next"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_still_failing_lists_new_failures():
|
|
76
|
+
s = api.summary(change_test([row("1", "same", before="FAIL", after="FAIL", new_failures=["calls cancel_order"])]), "u")
|
|
77
|
+
assert s["still_failing"][0]["new_failures"] == ["calls cancel_order"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_flagged_prior_is_surfaced():
|
|
81
|
+
rca = {"status": "done", "result": {**DONE_RCA["result"], "prior": {"cause": "MODEL", "flagged": True}}}
|
|
82
|
+
d = api.diagnosis(rca)
|
|
83
|
+
assert d["prior_disagrees"]["cause"] == "MODEL"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_wait_returns_once_runs_and_diagnoses_finish():
|
|
87
|
+
c = Fake([change_test([row("1", "pending")], done=1, verdict="pending"),
|
|
88
|
+
change_test([row("1", "regressed", rca={"status": "running"})]),
|
|
89
|
+
change_test([row("1", "regressed", rca=DONE_RCA)])])
|
|
90
|
+
t = c.wait("t1", timeout=600, poll=5)
|
|
91
|
+
assert t["rows"][0]["rca"]["status"] == "done" and c.now == 10
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_wait_stops_waiting_for_a_diagnosis_that_never_starts():
|
|
95
|
+
c = Fake([change_test([row("1", "regressed")])])
|
|
96
|
+
t = c.wait("t1", timeout=600, poll=5, diag_grace=30)
|
|
97
|
+
assert t["rows"][0]["rca"] is None and c.now == 30
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_wait_times_out():
|
|
101
|
+
c = Fake([change_test([row("1", "pending")], done=0, verdict="pending")])
|
|
102
|
+
c.wait("t1", timeout=20, poll=5)
|
|
103
|
+
assert c.now == 20
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_wait_for_chosen_items_only():
|
|
107
|
+
c = Fake([change_test([row("1", "regressed", rca=DONE_RCA), row("2", "same", before="FAIL", after="FAIL", rca={"status": "queued"})]),
|
|
108
|
+
change_test([row("1", "regressed", rca=DONE_RCA), row("2", "same", before="FAIL", after="FAIL", rca=DONE_RCA)])])
|
|
109
|
+
t = c.wait("t1", timeout=600, poll=5, item_ids=["2"])
|
|
110
|
+
assert t["rows"][1]["rca"]["status"] == "done"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_start_sends_only_what_changed():
|
|
114
|
+
c = Fake([change_test([])])
|
|
115
|
+
c.start_change_test("my bot", system_prompt="new", model=None, note="n")
|
|
116
|
+
assert c.calls[-1] == ("POST", "/projects/my%20bot/test-change", {"system_prompt": "new", "note": "n"})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_no_key_is_a_clear_error(monkeypatch):
|
|
120
|
+
monkeypatch.delenv("NINJAEVAL_API_KEY", raising=False)
|
|
121
|
+
monkeypatch.delenv("NINJAEVAL_API_KEY_FILE", raising=False)
|
|
122
|
+
with pytest.raises(api.NinjaEvalError, match="NINJAEVAL_API_KEY"):
|
|
123
|
+
api.Client().call("GET", "/me")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_key_file(tmp_path, monkeypatch):
|
|
127
|
+
f = tmp_path / "key"
|
|
128
|
+
f.write_text("efk_abc\n")
|
|
129
|
+
monkeypatch.delenv("NINJAEVAL_API_KEY", raising=False)
|
|
130
|
+
monkeypatch.setenv("NINJAEVAL_API_KEY_FILE", str(f))
|
|
131
|
+
assert api.Client().key == "efk_abc"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_test_url():
|
|
135
|
+
assert Fake([{}]).test_url("t1") == "https://app.example/#/change/t1"
|