"""Tests for scripts/atocore_client.py — the shared operator CLI. Specifically covers the Phase 9 reflection-loop subcommands added after codex's sequence-step-3 review: ``capture``, ``extract``, ``reinforce-interaction``, ``list-interactions``, ``get-interaction``, ``queue``, ``promote``, ``reject``. The tests mock the client's ``request()`` helper and verify each subcommand: - calls the correct HTTP method and path - builds the correct JSON body (or the correct query string) - passes the right subset of CLI arguments through This is the same "wiring test" shape used by tests/test_api_storage.py: we don't exercise the live HTTP stack; we verify the client builds the request correctly. The server side is already covered by its own route tests. """ from __future__ import annotations import json import sys from pathlib import Path import pytest # Make scripts/ importable _REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_REPO_ROOT / "scripts")) import atocore_client as client # noqa: E402 # --------------------------------------------------------------------------- # Request capture helper # --------------------------------------------------------------------------- class _RequestCapture: """Drop-in replacement for client.request() that records calls.""" def __init__(self, response: dict | None = None): self.calls: list[dict] = [] self._response = response if response is not None else {"ok": True} def __call__(self, method, path, data=None, timeout=None): self.calls.append( {"method": method, "path": path, "data": data, "timeout": timeout} ) return self._response @pytest.fixture def capture_requests(monkeypatch): """Replace client.request with a recording stub and return it.""" stub = _RequestCapture() monkeypatch.setattr(client, "request", stub) return stub def _run_client(monkeypatch, argv: list[str]) -> int: """Simulate a CLI invocation with the given argv.""" monkeypatch.setattr(sys, "argv", ["atocore_client.py", *argv]) return client.main() # --------------------------------------------------------------------------- # capture # --------------------------------------------------------------------------- def test_capture_posts_to_interactions_endpoint(capture_requests, monkeypatch): _run_client( monkeypatch, [ "capture", "what is p05's current focus", "The current focus is wave 2 operational ingestion.", "p05-interferometer", "claude-code-test", "session-abc", ], ) assert len(capture_requests.calls) == 1 call = capture_requests.calls[0] assert call["method"] == "POST" assert call["path"] == "/interactions" body = call["data"] assert body["prompt"] == "what is p05's current focus" assert body["response"].startswith("The current focus") assert body["project"] == "p05-interferometer" assert body["client"] == "claude-code-test" assert body["session_id"] == "session-abc" assert body["reinforce"] is True # default def test_capture_sets_default_client_when_omitted(capture_requests, monkeypatch): _run_client( monkeypatch, ["capture", "hi", "hello"], ) call = capture_requests.calls[0] assert call["data"]["client"] == "atocore-client" assert call["data"]["project"] == "" assert call["data"]["reinforce"] is True def test_capture_accepts_reinforce_false(capture_requests, monkeypatch): _run_client( monkeypatch, ["capture", "prompt", "response", "p05", "claude", "sess", "false"], ) call = capture_requests.calls[0] assert call["data"]["reinforce"] is False # --------------------------------------------------------------------------- # extract # --------------------------------------------------------------------------- def test_extract_default_is_preview(capture_requests, monkeypatch): _run_client(monkeypatch, ["extract", "abc-123"]) call = capture_requests.calls[0] assert call["method"] == "POST" assert call["path"] == "/interactions/abc-123/extract" assert call["data"] == {"persist": False} def test_extract_persist_true(capture_requests, monkeypatch): _run_client(monkeypatch, ["extract", "abc-123", "true"]) call = capture_requests.calls[0] assert call["data"] == {"persist": True} def test_extract_url_encodes_interaction_id(capture_requests, monkeypatch): _run_client(monkeypatch, ["extract", "abc/def"]) call = capture_requests.calls[0] assert call["path"] == "/interactions/abc%2Fdef/extract" # --------------------------------------------------------------------------- # reinforce-interaction # --------------------------------------------------------------------------- def test_reinforce_interaction_posts_to_correct_path(capture_requests, monkeypatch): _run_client(monkeypatch, ["reinforce-interaction", "int-xyz"]) call = capture_requests.calls[0] assert call["method"] == "POST" assert call["path"] == "/interactions/int-xyz/reinforce" assert call["data"] == {} # --------------------------------------------------------------------------- # list-interactions # --------------------------------------------------------------------------- def test_list_interactions_no_filters(capture_requests, monkeypatch): _run_client(monkeypatch, ["list-interactions"]) call = capture_requests.calls[0] assert call["method"] == "GET" assert call["path"] == "/interactions?limit=50" def test_list_interactions_with_project_filter(capture_requests, monkeypatch): _run_client(monkeypatch, ["list-interactions", "p05-interferometer"]) call = capture_requests.calls[0] assert "project=p05-interferometer" in call["path"] assert "limit=50" in call["path"] def test_list_interactions_full_filter_set(capture_requests, monkeypatch): _run_client( monkeypatch, [ "list-interactions", "p05", "sess-1", "claude-code", "2026-04-07T00:00:00Z", "20", ], ) call = capture_requests.calls[0] path = call["path"] assert "project=p05" in path assert "session_id=sess-1" in path assert "client=claude-code" in path # Since is URL-encoded — the : and + chars get escaped assert "since=2026-04-07" in path assert "limit=20" in path # --------------------------------------------------------------------------- # get-interaction # --------------------------------------------------------------------------- def test_get_interaction_fetches_by_id(capture_requests, monkeypatch): _run_client(monkeypatch, ["get-interaction", "int-42"]) call = capture_requests.calls[0] assert call["method"] == "GET" assert call["path"] == "/interactions/int-42" # --------------------------------------------------------------------------- # queue # --------------------------------------------------------------------------- def test_queue_always_filters_by_candidate_status(capture_requests, monkeypatch): _run_client(monkeypatch, ["queue"]) call = capture_requests.calls[0] assert call["method"] == "GET" assert call["path"].startswith("/memory?") assert "status=candidate" in call["path"] assert "limit=50" in call["path"] def test_queue_with_memory_type_and_project(capture_requests, monkeypatch): _run_client(monkeypatch, ["queue", "adaptation", "p05-interferometer", "10"]) call = capture_requests.calls[0] path = call["path"] assert "status=candidate" in path assert "memory_type=adaptation" in path assert "project=p05-interferometer" in path assert "limit=10" in path def test_queue_limit_coercion(capture_requests, monkeypatch): """limit is typed as int by argparse so string '25' becomes 25.""" _run_client(monkeypatch, ["queue", "", "", "25"]) call = capture_requests.calls[0] assert "limit=25" in call["path"] # --------------------------------------------------------------------------- # promote / reject # --------------------------------------------------------------------------- def test_promote_posts_to_memory_promote_path(capture_requests, monkeypatch): _run_client(monkeypatch, ["promote", "mem-abc"]) call = capture_requests.calls[0] assert call["method"] == "POST" assert call["path"] == "/memory/mem-abc/promote" assert call["data"] == {} def test_reject_posts_to_memory_reject_path(capture_requests, monkeypatch): _run_client(monkeypatch, ["reject", "mem-xyz"]) call = capture_requests.calls[0] assert call["method"] == "POST" assert call["path"] == "/memory/mem-xyz/reject" assert call["data"] == {} def test_promote_url_encodes_memory_id(capture_requests, monkeypatch): _run_client(monkeypatch, ["promote", "mem/with/slashes"]) call = capture_requests.calls[0] assert "mem%2Fwith%2Fslashes" in call["path"] # --------------------------------------------------------------------------- # end-to-end: ensure the Phase 9 loop can be driven entirely through # the client # --------------------------------------------------------------------------- def test_phase9_full_loop_via_client_shape(capture_requests, monkeypatch): """Simulate the full capture -> extract -> queue -> promote cycle. This doesn't exercise real HTTP — each call is intercepted by the mock request. But it proves every step of the Phase 9 loop is reachable through the shared client, which is the whole point of the codex-step-3 work. """ # Step 1: capture _run_client( monkeypatch, [ "capture", "what about GF-PTFE for lateral support", "## Decision: use GF-PTFE pads for thermal stability", "p05-interferometer", ], ) # Step 2: extract candidates (preview) _run_client(monkeypatch, ["extract", "fake-interaction-id"]) # Step 3: extract and persist _run_client(monkeypatch, ["extract", "fake-interaction-id", "true"]) # Step 4: list the review queue _run_client(monkeypatch, ["queue"]) # Step 5: promote a candidate _run_client(monkeypatch, ["promote", "fake-memory-id"]) # Step 6: reject another _run_client(monkeypatch, ["reject", "fake-memory-id-2"]) methods_and_paths = [ (c["method"], c["path"]) for c in capture_requests.calls ] assert methods_and_paths == [ ("POST", "/interactions"), ("POST", "/interactions/fake-interaction-id/extract"), ("POST", "/interactions/fake-interaction-id/extract"), ("GET", "/memory?status=candidate&limit=50"), ("POST", "/memory/fake-memory-id/promote"), ("POST", "/memory/fake-memory-id-2/reject"), ]