2.1.1 — MCP Phase 0: every high-level op returns an envelope (return-not-print)
Internal refactor per docs/MCP-SERVER-SPEC.md §4; CLI output and exit codes unchanged (wrappers reproduce them from the envelopes): - echo_ops: capture_op/resolve_op/link_op — duplicate gate and offline queueing are data (action: duplicate-gate + candidates; queued: true); the --json stdout-swallow hack is deleted (helper chatter -> stderr inside the cores); capture_op takes body_text= directly (the MCP path, no temp file). - echo_recall.recall_op — one structured result backs prose and --json. - echo_triage.list_op/route_op, echo_reflect.apply_op, echo_session.session_end_op — call capture_op internally, count gate outcomes from envelopes. - echo.scope_show_op/scope_set_op/load_op (+ shared _load_gather), and echo_doctor.run_op (checks as data). New eval/test_ops_api.py asserts the contract for every core: envelope shape AND stdout purity (a stray print would corrupt an MCP response stream). All six suites green. The 2.2 server build now starts directly at the app layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""test_ops_api.py — Phase 0 (return-not-print) contract: every high-level op has a
|
||||
core `*_op` function that RETURNS an envelope dict and prints nothing to stdout.
|
||||
|
||||
This is the seam the MCP server wraps (docs/MCP-SERVER-SPEC.md §4): the CLI wrappers
|
||||
are tested by the other suites; here we call the cores in-process against the mock and
|
||||
assert (a) the envelope shapes and (b) stdout purity — a stray print() would corrupt
|
||||
an MCP stdio/HTTP response stream.
|
||||
|
||||
Run: python test_ops_api.py [--port 8850]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCRIPTS = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts"
|
||||
KEY = "test-key-not-a-real-secret"
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=8850)
|
||||
a = ap.parse_args()
|
||||
BASE = f"http://127.0.0.1:{a.port}"
|
||||
|
||||
# env must be set BEFORE importing echo (it resolves config at import time)
|
||||
os.environ.update(ECHO_BASE=BASE, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21",
|
||||
ECHO_NOW="2300", ECHO_STATE_DIR=tempfile.mkdtemp(), ECHO_VERIFY="0")
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
print(f"{'ok ' if cond else 'FAIL'} {name}" + (f" -- {detail}" if not cond else ""))
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def http(method, url, body=None):
|
||||
data = body.encode() if isinstance(body, str) else body
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Authorization": f"Bearer {KEY}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace")
|
||||
except Exception as e: # noqa: BLE001
|
||||
return getattr(e, "code", 0), ""
|
||||
|
||||
|
||||
def pure(fn, *args, **kw):
|
||||
"""Call fn capturing stdout; return (result, captured_stdout)."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
out = fn(*args, **kw)
|
||||
return out, buf.getvalue()
|
||||
|
||||
|
||||
def main():
|
||||
srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
for _ in range(50):
|
||||
try:
|
||||
urllib.request.urlopen(f"{BASE}/__debug__reset", data=b"", timeout=1)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
http("PUT", f"{BASE}/vault/_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
|
||||
http("PUT", f"{BASE}/vault/_agent/context/current-context.md",
|
||||
"---\ntype: context-bundle\nscope_updated: \"2026-06-20\"\ncreated: 2026-06-01\n---\n"
|
||||
"# Current Context\n\n## Scope\ntesting phase 0\n\n## Scope History\n")
|
||||
|
||||
import echo
|
||||
import echo_doctor
|
||||
import echo_ops
|
||||
import echo_recall
|
||||
import echo_reflect
|
||||
import echo_session
|
||||
import echo_triage
|
||||
|
||||
# capture_op: create / duplicate-gate / dry-run — envelopes, stdout pure
|
||||
env, out = pure(echo_ops.capture_op, "person", "Zara Quix", body_text="Met at the expo.")
|
||||
check("capture_op create envelope", env.get("ok") is True and env.get("action") == "created"
|
||||
and env.get("path") == "resources/people/zara-quix.md", json.dumps(env))
|
||||
check("capture_op prints nothing to stdout", out == "", out[:200])
|
||||
env, out = pure(echo_ops.capture_op, "person", "Zara Quix Junior", body_text="")
|
||||
check("capture_op duplicate-gate is data, not an exit code",
|
||||
env.get("ok") is False and env.get("action") == "duplicate-gate"
|
||||
and env.get("candidates"), json.dumps(env))
|
||||
check("capture_op gate prints nothing", out == "", out[:200])
|
||||
env, out = pure(echo_ops.capture_op, "company", "Plan Co", body_text="", dry_run=True)
|
||||
check("capture_op dry-run envelope", env.get("action") == "dry-run:create"
|
||||
and env.get("path") == "resources/companies/plan-co.md", json.dumps(env))
|
||||
|
||||
# resolve_op / link_op
|
||||
env, out = pure(echo_ops.resolve_op, "zara quix")
|
||||
check("resolve_op returns the match dict", env.get("match") is True
|
||||
and env.get("path") == "resources/people/zara-quix.md", json.dumps(env))
|
||||
pure(echo_ops.capture_op, "concept", "Gizmo", body_text="")
|
||||
env, out = pure(echo_ops.link_op, "resources/people/zara-quix.md", "resources/concepts/gizmo.md")
|
||||
check("link_op envelope", env.get("ok") is True and env.get("a_changed") in (True, False),
|
||||
json.dumps(env))
|
||||
check("link_op prints nothing", out == "", out[:200])
|
||||
|
||||
# recall_op
|
||||
env, out = pure(echo_recall.recall_op, "expo")
|
||||
check("recall_op envelope shape", env.get("action") == "recall"
|
||||
and isinstance(env.get("primary"), list) and isinstance(env.get("linked"), list),
|
||||
json.dumps(env)[:200])
|
||||
check("recall_op prints nothing", out == "", out[:200])
|
||||
|
||||
# triage list_op / route_op (dry-run)
|
||||
http("PUT", f"{BASE}/vault/inbox/captures/inbox.md", "- 2026-06-10: try the quorlab tool\n")
|
||||
env, out = pure(echo_triage.list_op)
|
||||
check("triage list_op envelope", env.get("count") == 1
|
||||
and env["items"][0]["age_days"] == 11, json.dumps(env))
|
||||
props = [{"title": "Quorlab Tool", "kind": "reference", "confidence": 0.9,
|
||||
"line": "- 2026-06-10: try the quorlab tool"}]
|
||||
env, out = pure(echo_triage.route_op, props, False)
|
||||
check("triage route_op dry-run rows", env.get("dry_run") is True
|
||||
and env["rows"][0]["action"] == "create", json.dumps(env))
|
||||
check("triage route_op prints nothing", out == "", out[:200])
|
||||
|
||||
# reflect apply_op (applied path, using capture_op internally)
|
||||
env, out = pure(echo_reflect.apply_op,
|
||||
[{"title": "Blorp Pattern", "kind": "semantic",
|
||||
"body": "The operator prefers blorp.", "confidence": 0.9}], True)
|
||||
check("reflect apply_op applies via capture_op", env.get("applied") == 1
|
||||
and env["results"][0]["action"] == "created", json.dumps(env))
|
||||
check("reflect apply_op prints nothing", out == "", out[:200])
|
||||
|
||||
# scope ops
|
||||
env, out = pure(echo.scope_show_op)
|
||||
check("scope_show_op returns scope + freshness", env.get("scope") == "testing phase 0"
|
||||
and env.get("scope_updated") == "2026-06-20", json.dumps(env))
|
||||
env, out = pure(echo.scope_set_op, "phase zero refactor")
|
||||
check("scope_set_op envelope", env.get("action") == "scope-set"
|
||||
and env.get("scope_updated") == "2026-06-21", json.dumps(env))
|
||||
check("scope_set_op prints nothing", out == "", out[:200])
|
||||
|
||||
# load_op (brief)
|
||||
env, out = pure(echo.load_op, True)
|
||||
check("load_op returns sections + brief", "marker" in env.get("sections", {})
|
||||
and "ECHO load (brief)" in env.get("brief", ""), json.dumps(env)[:200])
|
||||
check("load_op prints nothing", out == "", out[:200])
|
||||
|
||||
# doctor run_op
|
||||
env, out = pure(echo_doctor.run_op)
|
||||
check("doctor run_op checks list", isinstance(env.get("checks"), list)
|
||||
and env.get("fatal") is None and env.get("ok") is True, json.dumps(env))
|
||||
check("doctor run_op prints nothing", out == "", out[:200])
|
||||
|
||||
# session_end_op: dry-run envelope; bad bundle raises EchoError(2) pre-write
|
||||
bundle = {"slug": "phase-zero", "log_body": "---\ntype: session-log\n---\n# S\n\n## Goal\nx\n"}
|
||||
env, out = pure(echo_session.session_end_op, bundle, False)
|
||||
check("session_end_op dry-run envelope", env.get("dry_run") is True
|
||||
and env.get("path") == "_agent/sessions/2026-06-21-2300-phase-zero.md", json.dumps(env))
|
||||
check("session_end_op prints nothing", out == "", out[:200])
|
||||
try:
|
||||
echo_session.session_end_op({"slug": "Bad Slug!", "log_body": "x"}, True)
|
||||
check("session_end_op rejects a bad slug", False, "no exception raised")
|
||||
except RuntimeError as exc:
|
||||
check("session_end_op rejects a bad slug", getattr(exc, "code", 0) == 2, str(exc))
|
||||
|
||||
print(f"\n{len(failures)} failure(s)" if failures else "\nall ops-api tests passed")
|
||||
return 1 if failures else 0
|
||||
finally:
|
||||
srv.terminate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user