314 lines
11 KiB
Python
314 lines
11 KiB
Python
|
|
"""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"),
|
||
|
|
]
|