ecp-runtime 0.2.9b0__tar.gz → 0.3.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.
@@ -31,7 +31,7 @@ MANIFEST
31
31
  # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
32
  *.manifest
33
33
  *.spec
34
-
34
+ /specs/
35
35
  # Installer logs
36
36
  pip-log.txt
37
37
  pip-delete-this-directory.txt
@@ -50,7 +50,10 @@ coverage.xml
50
50
  .hypothesis/
51
51
  .pytest_cache/
52
52
  cover/
53
-
53
+ node_modules/
54
+ .github/agents
55
+ .github/prompts/
56
+ .specify/
54
57
  # Translations
55
58
  *.mo
56
59
  *.pot
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecp-runtime
3
- Version: 0.2.9b0
3
+ Version: 0.3.0
4
4
  Summary: The reference runtime for the Evaluation Context Protocol (ECP).
5
5
  Project-URL: Homepage, https://github.com/evaluation-context-protocol/ecp
6
6
  Project-URL: Documentation, https://evaluationcontextprotocol.io/
@@ -24,9 +24,11 @@ It includes the CLI tool `ecp` for running agent evaluations.
24
24
  ## Install
25
25
 
26
26
  ```bash
27
- pip install ecp-runtime
27
+ pip install "ecp-runtime==0.2.9"
28
28
  ```
29
29
 
30
+ The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
31
+
30
32
  ## Usage
31
33
 
32
34
  Run an evaluation manifest:
@@ -41,6 +43,13 @@ You can also run via module entrypoint:
41
43
  python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
42
44
  ```
43
45
 
46
+ Manifest `target` values may be either a command for the default stdio transport
47
+ or an ECP Streamable HTTP endpoint:
48
+
49
+ ```yaml
50
+ target: "http://127.0.0.1:8765/ecp"
51
+ ```
52
+
44
53
  If your manifest includes `llm_judge`, set an API key and optional judge model:
45
54
 
46
55
  ```bash
@@ -6,9 +6,11 @@ It includes the CLI tool `ecp` for running agent evaluations.
6
6
  ## Install
7
7
 
8
8
  ```bash
9
- pip install ecp-runtime
9
+ pip install "ecp-runtime==0.2.9"
10
10
  ```
11
11
 
12
+ The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
13
+
12
14
  ## Usage
13
15
 
14
16
  Run an evaluation manifest:
@@ -23,6 +25,13 @@ You can also run via module entrypoint:
23
25
  python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
24
26
  ```
25
27
 
28
+ Manifest `target` values may be either a command for the default stdio transport
29
+ or an ECP Streamable HTTP endpoint:
30
+
31
+ ```yaml
32
+ target: "http://127.0.0.1:8765/ecp"
33
+ ```
34
+
26
35
  If your manifest includes `llm_judge`, set an API key and optional judge model:
27
36
 
28
37
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ecp-runtime"
7
- version = "0.2.9b0"
7
+ version = "0.3.0"
8
8
  description = "The reference runtime for the Evaluation Context Protocol (ECP)."
9
9
  authors = [
10
10
  { name = "ECP Maintainers", email = "hello@ecp.org" },
@@ -29,4 +29,3 @@ Issues = "https://github.com/evaluation-context-protocol/ecp/issues"
29
29
  [project.scripts]
30
30
  ecp = "ecp_runtime.cli:app"
31
31
 
32
-
@@ -0,0 +1 @@
1
+ __version__ = "0.2.9"
@@ -1,12 +1,15 @@
1
- import logging
2
- import typer
3
- import sys
4
- import os
1
+ import glob
5
2
  import json
3
+ import logging
4
+ import os
5
+ import sys
6
6
  from pathlib import Path
7
- from typing import Dict, Any, Optional
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import typer
8
10
 
9
11
  from .reporter import HTMLReporter
12
+ from .trend import RunTrendAnalyzer
10
13
 
11
14
  # Import local modules (Using relative imports)
12
15
  try:
@@ -137,5 +140,66 @@ def run(
137
140
  sys.exit(1)
138
141
 
139
142
 
143
+ @app.command()
144
+ def trend(
145
+ pattern: str = typer.Argument(
146
+ ...,
147
+ help="Glob pattern matching saved JSON report files (e.g. 'results/run-*.json')",
148
+ ),
149
+ window: int = typer.Option(
150
+ 20,
151
+ "--window",
152
+ "-w",
153
+ help="Maximum number of recent runs to include in the analysis",
154
+ min=1,
155
+ ),
156
+ exit_on_regression: bool = typer.Option(
157
+ False,
158
+ "--exit-on-regression",
159
+ help="Exit with code 2 when a degrading trend is detected (for CI gates)",
160
+ ),
161
+ verbose: bool = typer.Option(
162
+ False,
163
+ "--verbose",
164
+ "-v",
165
+ help="Enable verbose output",
166
+ ),
167
+ ):
168
+ """
169
+ Analyse pass-rate trends across a sequence of saved JSON report files.
170
+ """
171
+ _configure_logging(verbose)
172
+
173
+ matched: List[Path] = sorted(Path(path) for path in glob.glob(pattern, recursive=True))
174
+
175
+ if not matched:
176
+ typer.echo(f"No files matched the pattern: {pattern}", err=True)
177
+ raise typer.Exit(code=1)
178
+
179
+ typer.echo(f"Found {len(matched)} report(s). Analysing last {window}...")
180
+
181
+ analyzer = RunTrendAnalyzer(matched, window=window)
182
+ report = analyzer.analyze()
183
+
184
+ typer.echo("")
185
+ typer.echo(f"{'Run':<5} {'Manifest':<45} {'Passed':>6} {'Total':>6} {'Rate':>6}")
186
+ typer.echo("-" * 74)
187
+ for index, run in enumerate(report.runs, start=1):
188
+ manifest_label = Path(run.manifest).name if len(run.manifest) > 45 else run.manifest
189
+ rate_pct = f"{run.pass_rate * 100:.1f}%"
190
+ typer.echo(f"{index:<5} {manifest_label:<45} {run.passed:>6} {run.total:>6} {rate_pct:>6}")
191
+
192
+ typer.echo("")
193
+ typer.echo(f"Trend Direction : {report.direction.upper()}")
194
+ typer.echo(f"Slope (per run) : {report.pass_rate_slope:+.6f}")
195
+ typer.echo(f"Window : {len(report.runs)} run(s) analysed (max {report.window})")
196
+ typer.echo(f"Regression flag : {'YES' if report.any_regression else 'No'}")
197
+ typer.echo("")
198
+
199
+ if report.any_regression and exit_on_regression:
200
+ typer.echo("Regression detected. Exiting with code 2 (--exit-on-regression).", err=True)
201
+ raise typer.Exit(code=2)
202
+
203
+
140
204
  if __name__ == "__main__":
141
205
  app()
@@ -1,6 +1,7 @@
1
- import re
2
1
  import os
2
+ import re
3
3
  from typing import Any, Dict, List, Tuple
4
+
4
5
  from .manifest import GraderConfig, StepConfig
5
6
 
6
7
  # Try importing OpenAI, but don't crash if it's missing (unless used)
@@ -1,6 +1,8 @@
1
1
  from typing import Any, Dict, List, Literal, Optional
2
- from pydantic import BaseModel, Field, model_validator
2
+
3
3
  import yaml
4
+ from pydantic import BaseModel, Field, model_validator
5
+
4
6
 
5
7
  # --- The Grader (Assertion) Schema ---
6
8
  class GraderConfig(BaseModel):
@@ -5,15 +5,18 @@ Simplified Version. V0.1
5
5
  AsyncIO Pending
6
6
  """
7
7
 
8
- import subprocess
9
8
  import json
10
- import os
11
- import time
12
9
  import logging
13
- import threading
10
+ import os
14
11
  import queue
15
- from typing import Dict, Any, Optional, List
12
+ import subprocess
13
+ import threading
14
+ import time
16
15
  from dataclasses import dataclass
16
+ from typing import Any, Dict, List, Optional
17
+ from urllib import error, request
18
+ from urllib.parse import urlparse
19
+
17
20
  from .graders import evaluate_step
18
21
 
19
22
  logger = logging.getLogger(__name__)
@@ -147,6 +150,75 @@ class AgentProcess:
147
150
  return ""
148
151
 
149
152
 
153
+ class HTTPAgentClient:
154
+ """JSON-RPC client for ECP Streamable HTTP endpoints."""
155
+
156
+ def __init__(self, endpoint: str, rpc_timeout: float = 30.0):
157
+ self.endpoint = endpoint
158
+ self.rpc_timeout = rpc_timeout
159
+
160
+ def start(self):
161
+ return None
162
+
163
+ def stop(self):
164
+ return None
165
+
166
+ def send_rpc(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
167
+ if not params:
168
+ params = {}
169
+
170
+ payload = {
171
+ "jsonrpc": "2.0",
172
+ "method": method,
173
+ "params": params,
174
+ "id": int(time.time() * 1000),
175
+ }
176
+ body = json.dumps(payload).encode("utf-8")
177
+ req = request.Request(
178
+ self.endpoint,
179
+ data=body,
180
+ method="POST",
181
+ headers={
182
+ "Accept": "application/json, text/event-stream",
183
+ "Content-Type": "application/json",
184
+ },
185
+ )
186
+
187
+ try:
188
+ with request.urlopen(req, timeout=self.rpc_timeout) as resp:
189
+ content_type = resp.headers.get("Content-Type", "")
190
+ raw = resp.read().decode("utf-8")
191
+ except error.HTTPError as exc:
192
+ raw = exc.read().decode("utf-8", errors="replace")
193
+ raise RuntimeError(
194
+ f"HTTP RPC failed: status={exc.code}, body={raw or exc.reason}"
195
+ ) from exc
196
+ except error.URLError as exc:
197
+ raise RuntimeError(f"HTTP RPC failed: {exc.reason}") from exc
198
+
199
+ if "text/event-stream" in content_type:
200
+ return self._parse_sse_response(raw)
201
+ if not raw:
202
+ raise RuntimeError("HTTP RPC response was empty")
203
+ payload = json.loads(raw)
204
+ if not isinstance(payload, dict):
205
+ raise RuntimeError("HTTP RPC response must be a JSON object")
206
+ return payload
207
+
208
+ def _parse_sse_response(self, raw: str) -> Dict[str, Any]:
209
+ for event in raw.split("\n\n"):
210
+ data_lines = []
211
+ for line in event.splitlines():
212
+ if line.startswith("data:"):
213
+ data_lines.append(line[5:].lstrip())
214
+ if not data_lines:
215
+ continue
216
+ payload = json.loads("\n".join(data_lines))
217
+ if isinstance(payload, dict) and ("result" in payload or "error" in payload):
218
+ return payload
219
+ raise RuntimeError("SSE stream ended without a JSON-RPC response")
220
+
221
+
150
222
  class ECPRunner:
151
223
  """The Orchestrator."""
152
224
 
@@ -162,7 +234,7 @@ class ECPRunner:
162
234
  logger.info("Scenario: %s", scenario.name)
163
235
 
164
236
  rpc_timeout = float(os.environ.get("ECP_RPC_TIMEOUT", "30"))
165
- agent = AgentProcess(self.manifest.target, rpc_timeout=rpc_timeout)
237
+ agent = self._create_agent(self.manifest.target, rpc_timeout=rpc_timeout)
166
238
  agent.start()
167
239
 
168
240
  try:
@@ -224,6 +296,11 @@ class ECPRunner:
224
296
  "scenarios": report_data
225
297
  }
226
298
 
299
+ def _create_agent(self, target: str, rpc_timeout: float):
300
+ if _is_http_url(target):
301
+ return HTTPAgentClient(target, rpc_timeout=rpc_timeout)
302
+ return AgentProcess(target, rpc_timeout=rpc_timeout)
303
+
227
304
  def _ensure_rpc_success(
228
305
  self,
229
306
  rpc_resp: Dict[str, Any],
@@ -243,3 +320,8 @@ class ECPRunner:
243
320
  raise RuntimeError(
244
321
  f"RPC call failed ({method}) at {where}: code={code}, message={message}"
245
322
  )
323
+
324
+
325
+ def _is_http_url(target: str) -> bool:
326
+ parsed = urlparse(target)
327
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
@@ -0,0 +1,113 @@
1
+ """
2
+ ecp_runtime.trend
3
+ =================
4
+ RunTrendAnalyzer - reads a sequence of saved JSON report files produced by
5
+ ``ecp run --json-out`` and computes cross-run pass-rate regression signals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import statistics
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import List, Literal
15
+
16
+
17
+ @dataclass
18
+ class RunPoint:
19
+ """Represents a single evaluation run extracted from one JSON report."""
20
+
21
+ manifest: str
22
+ passed: int
23
+ total: int
24
+ pass_rate: float
25
+
26
+
27
+ @dataclass
28
+ class RunTrendReport:
29
+ """Aggregated analysis over a window of sequential runs."""
30
+
31
+ window: int
32
+ runs: List[RunPoint] = field(default_factory=list)
33
+ pass_rate_slope: float = 0.0
34
+ direction: Literal["improving", "degrading", "stable"] = "stable"
35
+ any_regression: bool = False
36
+
37
+
38
+ class RunTrendAnalyzer:
39
+ """
40
+ Analyse cross-run pass-rate trends across a collection of JSON reports.
41
+
42
+ The analyzer sorts report paths lexicographically and keeps only the most
43
+ recent ``window`` reports.
44
+ """
45
+
46
+ _IMPROVING_THRESHOLD: float = 0.001
47
+ _DEGRADING_THRESHOLD: float = -0.001
48
+
49
+ def __init__(self, report_paths: List[Path], window: int = 20) -> None:
50
+ if window < 1:
51
+ raise ValueError(f"window must be >= 1, got {window}")
52
+ self.report_paths: List[Path] = sorted(report_paths)[-window:]
53
+ self.window = window
54
+
55
+ def analyze(self) -> RunTrendReport:
56
+ runs: List[RunPoint] = []
57
+
58
+ for path in self.report_paths:
59
+ point = self._load_run_point(path)
60
+ if point is not None:
61
+ runs.append(point)
62
+
63
+ if len(runs) < 2:
64
+ return RunTrendReport(
65
+ window=self.window,
66
+ runs=runs,
67
+ pass_rate_slope=0.0,
68
+ direction="stable",
69
+ any_regression=False,
70
+ )
71
+
72
+ slope = self._compute_slope([run.pass_rate for run in runs])
73
+ direction = self._classify(slope)
74
+
75
+ return RunTrendReport(
76
+ window=self.window,
77
+ runs=runs,
78
+ pass_rate_slope=round(slope, 6),
79
+ direction=direction,
80
+ any_regression=(direction == "degrading"),
81
+ )
82
+
83
+ @staticmethod
84
+ def _load_run_point(path: Path) -> RunPoint | None:
85
+ try:
86
+ data = json.loads(path.read_text(encoding="utf-8"))
87
+ except (OSError, json.JSONDecodeError):
88
+ return None
89
+
90
+ total = int(data.get("total", 0) or 0)
91
+ passed = int(data.get("passed", 0) or 0)
92
+ passed = max(0, min(passed, total))
93
+ pass_rate = passed / total if total > 0 else 0.0
94
+
95
+ return RunPoint(
96
+ manifest=data.get("manifest", str(path)),
97
+ passed=passed,
98
+ total=total,
99
+ pass_rate=pass_rate,
100
+ )
101
+
102
+ @staticmethod
103
+ def _compute_slope(pass_rates: List[float]) -> float:
104
+ x = list(range(len(pass_rates)))
105
+ slope, _intercept = statistics.linear_regression(x, pass_rates)
106
+ return slope
107
+
108
+ def _classify(self, slope: float) -> Literal["improving", "degrading", "stable"]:
109
+ if slope > self._IMPROVING_THRESHOLD:
110
+ return "improving"
111
+ if slope < self._DEGRADING_THRESHOLD:
112
+ return "degrading"
113
+ return "stable"
@@ -1,7 +1,7 @@
1
1
  import json
2
+ import sys
2
3
  import tempfile
3
4
  import unittest
4
- import sys
5
5
  from pathlib import Path
6
6
  from unittest import mock
7
7
 
@@ -9,9 +9,8 @@ RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
9
9
  if str(RUNTIME_SRC) not in sys.path:
10
10
  sys.path.insert(0, str(RUNTIME_SRC))
11
11
 
12
- from typer.testing import CliRunner
13
-
14
12
  from ecp_runtime.cli import app
13
+ from typer.testing import CliRunner
15
14
 
16
15
 
17
16
  class CLISmokeTests(unittest.TestCase):
@@ -0,0 +1,135 @@
1
+ import os
2
+ import socket
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import textwrap
7
+ import time
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ REPO_ROOT = Path(__file__).resolve().parents[3]
12
+ RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
13
+
14
+
15
+ class ExampleIntegrationTests(unittest.TestCase):
16
+ def _run_manifest(self, manifest_path: Path) -> subprocess.CompletedProcess[str]:
17
+ env = dict(os.environ)
18
+ existing = env.get("PYTHONPATH", "")
19
+ extra_paths = [str(RUNTIME_SRC)]
20
+ if existing:
21
+ extra_paths.append(existing)
22
+ env["PYTHONPATH"] = os.pathsep.join(extra_paths)
23
+
24
+ return subprocess.run(
25
+ [
26
+ sys.executable,
27
+ "-m",
28
+ "ecp_runtime.cli",
29
+ "run",
30
+ "--manifest",
31
+ str(manifest_path),
32
+ "--json",
33
+ ],
34
+ cwd=REPO_ROOT,
35
+ text=True,
36
+ capture_output=True,
37
+ env=env,
38
+ check=False,
39
+ )
40
+
41
+ def test_plain_python_demo_manifest_passes(self) -> None:
42
+ manifest = REPO_ROOT / "examples" / "plain_python_demo" / "manifest.yaml"
43
+ result = self._run_manifest(manifest)
44
+ self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
45
+ self.assertIn('"failed": 0', result.stdout)
46
+
47
+ def test_two_agent_demo_manifest_passes(self) -> None:
48
+ manifest = REPO_ROOT / "examples" / "two_agent_demo" / "manifest.yaml"
49
+ result = self._run_manifest(manifest)
50
+ self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
51
+ self.assertIn('"failed": 0', result.stdout)
52
+
53
+ def test_streamable_http_demo_manifest_passes(self) -> None:
54
+ port = self._free_port()
55
+ server_env = dict(os.environ)
56
+ sdk_src = REPO_ROOT / "sdk" / "python" / "src"
57
+ existing = server_env.get("PYTHONPATH", "")
58
+ extra_paths = [str(sdk_src)]
59
+ if existing:
60
+ extra_paths.append(existing)
61
+ server_env["PYTHONPATH"] = os.pathsep.join(extra_paths)
62
+ server_env["ECP_HTTP_PORT"] = str(port)
63
+
64
+ server = subprocess.Popen(
65
+ [sys.executable, str(REPO_ROOT / "examples" / "streamable_http_demo" / "agent.py")],
66
+ cwd=REPO_ROOT,
67
+ text=True,
68
+ stdout=subprocess.PIPE,
69
+ stderr=subprocess.PIPE,
70
+ env=server_env,
71
+ )
72
+
73
+ try:
74
+ self._wait_for_port("127.0.0.1", port)
75
+ with tempfile.TemporaryDirectory() as tmpdir:
76
+ manifest_path = Path(tmpdir) / "streamable-http-manifest.yaml"
77
+ manifest_path.write_text(
78
+ textwrap.dedent(
79
+ f"""
80
+ manifest_version: "v1"
81
+ name: "Streamable HTTP Transport Validation"
82
+ target: "http://127.0.0.1:{port}/ecp"
83
+
84
+ scenarios:
85
+ - name: "HTTP Echo"
86
+ steps:
87
+ - input: "echo: transport is online"
88
+ graders:
89
+ - type: text_match
90
+ field: public_output
91
+ condition: contains
92
+ value: "transport is online"
93
+ - type: tool_usage
94
+ tool_name: "http_echo"
95
+ arguments:
96
+ message: "transport is online"
97
+ """
98
+ ).strip(),
99
+ encoding="utf-8",
100
+ )
101
+ result = self._run_manifest(manifest_path)
102
+
103
+ self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
104
+ self.assertIn('"failed": 0', result.stdout)
105
+ finally:
106
+ server.terminate()
107
+ try:
108
+ server.communicate(timeout=2)
109
+ except subprocess.TimeoutExpired:
110
+ server.kill()
111
+ server.communicate()
112
+
113
+ def _free_port(self) -> int:
114
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
115
+ sock.bind(("127.0.0.1", 0))
116
+ return int(sock.getsockname()[1])
117
+
118
+ def _wait_for_port(self, host: str, port: int) -> None:
119
+ deadline = time.time() + 5
120
+ while time.time() < deadline:
121
+ if self._port_is_open(host, port):
122
+ return
123
+ time.sleep(0.05)
124
+ raise AssertionError(f"Timed out waiting for {host}:{port}")
125
+
126
+ def _port_is_open(self, host: str, port: int) -> bool:
127
+ try:
128
+ with socket.create_connection((host, port), timeout=0.2):
129
+ return True
130
+ except OSError:
131
+ return False
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -1,5 +1,5 @@
1
- import unittest
2
1
  import sys
2
+ import unittest
3
3
  from pathlib import Path
4
4
  from types import SimpleNamespace
5
5
  from unittest import mock
@@ -9,7 +9,7 @@ if str(RUNTIME_SRC) not in sys.path:
9
9
  sys.path.insert(0, str(RUNTIME_SRC))
10
10
 
11
11
  from ecp_runtime.manifest import StepConfig
12
- from ecp_runtime.runner import ECPRunner
12
+ from ecp_runtime.runner import ECPRunner, HTTPAgentClient
13
13
 
14
14
 
15
15
  class RunnerTests(unittest.TestCase):
@@ -68,6 +68,50 @@ class RunnerTests(unittest.TestCase):
68
68
  self.assertIn("step=1", msg)
69
69
  self.assertIn("boom", msg)
70
70
 
71
+ def test_runner_uses_http_client_for_url_target(self) -> None:
72
+ runner = ECPRunner(SimpleNamespace(target="http://127.0.0.1:8765/ecp", scenarios=[]))
73
+
74
+ agent = runner._create_agent(runner.manifest.target, rpc_timeout=12.0)
75
+
76
+ self.assertIsInstance(agent, HTTPAgentClient)
77
+ self.assertEqual(agent.endpoint, "http://127.0.0.1:8765/ecp")
78
+ self.assertEqual(agent.rpc_timeout, 12.0)
79
+
80
+ def test_http_agent_client_posts_json_rpc(self) -> None:
81
+ class FakeResponse:
82
+ headers = {"Content-Type": "application/json; charset=utf-8"}
83
+
84
+ def __enter__(self):
85
+ return self
86
+
87
+ def __exit__(self, *_args):
88
+ return None
89
+
90
+ def read(self):
91
+ return b'{"jsonrpc":"2.0","id":1,"result":{"ok":true}}'
92
+
93
+ captured = {}
94
+
95
+ def fake_urlopen(req, timeout):
96
+ captured["url"] = req.full_url
97
+ captured["timeout"] = timeout
98
+ captured["body"] = req.data
99
+ captured["accept"] = req.headers.get("Accept")
100
+ return FakeResponse()
101
+
102
+ with mock.patch("ecp_runtime.runner.request.urlopen", fake_urlopen):
103
+ response = HTTPAgentClient("http://agent.test/ecp", rpc_timeout=3).send_rpc(
104
+ "agent/step", {"input": "hi"}
105
+ )
106
+
107
+ self.assertEqual(response["result"]["ok"], True)
108
+ self.assertEqual(captured["url"], "http://agent.test/ecp")
109
+ self.assertEqual(captured["timeout"], 3)
110
+ self.assertIn("application/json", captured["accept"])
111
+ body = captured["body"].decode("utf-8")
112
+ self.assertIn('"method": "agent/step"', body)
113
+ self.assertIn('"input": "hi"', body)
114
+
71
115
 
72
116
  if __name__ == "__main__":
73
117
  unittest.main()
@@ -0,0 +1,87 @@
1
+ import json
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ # Ensure srd is in path for imports
9
+ RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
10
+ if str(RUNTIME_SRC) not in sys.path:
11
+ sys.path.insert(0, str(RUNTIME_SRC))
12
+
13
+ from ecp_runtime.trend import RunPoint, RunTrendAnalyzer
14
+
15
+
16
+ class RunTrendAnalyzerTests(unittest.TestCase):
17
+ def test_analyzer_handles_improving_trend(self) -> None:
18
+ rates = [0.1, 0.5, 0.9]
19
+ runs = [RunPoint(manifest=f"r{i}", passed=int(r*10), total=10, pass_rate=r) for i, r in enumerate(rates)]
20
+
21
+ # We mock _load_run_point to avoid file IO for the core logic test
22
+ with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
23
+ analyzer = RunTrendAnalyzer([Path("r0"), Path("r1"), Path("r2")])
24
+ report = analyzer.analyze()
25
+
26
+ self.assertEqual(report.direction, "improving")
27
+ self.assertGreater(report.pass_rate_slope, 0.3)
28
+ self.assertFalse(report.any_regression)
29
+
30
+ def test_analyzer_handles_degrading_trend(self) -> None:
31
+ rates = [0.9, 0.5, 0.1]
32
+ runs = [RunPoint(manifest=f"r{i}", passed=int(r*10), total=10, pass_rate=r) for i, r in enumerate(rates)]
33
+
34
+ with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
35
+ analyzer = RunTrendAnalyzer([Path("r0"), Path("r1"), Path("r2")])
36
+ report = analyzer.analyze()
37
+
38
+ self.assertEqual(report.direction, "degrading")
39
+ self.assertLess(report.pass_rate_slope, -0.3)
40
+ self.assertTrue(report.any_regression)
41
+
42
+ def test_analyzer_handles_stable_trend(self) -> None:
43
+ rates = [0.5, 0.50001, 0.49999]
44
+ runs = [RunPoint(manifest=f"r{i}", passed=int(r*100), total=100, pass_rate=r) for i, r in enumerate(rates)]
45
+
46
+ with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
47
+ analyzer = RunTrendAnalyzer([Path("r0"), Path("r1"), Path("r2")])
48
+ report = analyzer.analyze()
49
+
50
+ self.assertEqual(report.direction, "stable")
51
+ self.assertAlmostEqual(report.pass_rate_slope, 0.0, places=4)
52
+ self.assertFalse(report.any_regression)
53
+
54
+ def test_analyzer_window_trimming(self) -> None:
55
+ paths = [Path(f"r{i}") for i in range(10)]
56
+ analyzer = RunTrendAnalyzer(paths, window=3)
57
+ self.assertEqual(len(analyzer.report_paths), 3)
58
+ self.assertEqual(analyzer.report_paths[-1].name, "r9")
59
+
60
+ def test_single_run_returns_stable(self) -> None:
61
+ runs = [RunPoint(manifest="r0", passed=5, total=10, pass_rate=0.5)]
62
+ with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
63
+ analyzer = RunTrendAnalyzer([Path("r0")])
64
+ report = analyzer.analyze()
65
+ self.assertEqual(report.direction, "stable")
66
+ self.assertEqual(report.pass_rate_slope, 0.0)
67
+
68
+
69
+ class RunTrendAnalyzerFileTests(unittest.TestCase):
70
+ def test_load_run_point_from_file(self) -> None:
71
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tmp:
72
+ data = {"passed": 8, "total": 10, "manifest": "test.yaml"}
73
+ json.dump(data, tmp)
74
+ tmp_path = Path(tmp.name)
75
+
76
+ try:
77
+ point = RunTrendAnalyzer._load_run_point(tmp_path)
78
+ self.assertIsNotNone(point)
79
+ self.assertEqual(point.passed, 8)
80
+ self.assertEqual(point.pass_rate, 0.8)
81
+ self.assertEqual(point.manifest, "test.yaml")
82
+ finally:
83
+ tmp_path.unlink()
84
+
85
+
86
+ if __name__ == "__main__":
87
+ unittest.main()
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,6 +1,6 @@
1
1
  import os
2
- import unittest
3
2
  import sys
3
+ import unittest
4
4
  from pathlib import Path
5
5
  from types import SimpleNamespace
6
6
  from unittest import mock
@@ -1,6 +1,6 @@
1
+ import sys
1
2
  import tempfile
2
3
  import unittest
3
- import sys
4
4
  from pathlib import Path
5
5
 
6
6
  from pydantic import ValidationError