Files
ATOCore/tests/test_atocore_client.py

314 lines
11 KiB
Python
Raw Normal View History

feat(client): Phase 9 reflection loop surface in shared operator CLI Codex's sequence step 3: finish the Phase 9 operator surface in the shared client. The previous client version (0.1.0) covered stable operations (project lifecycle, retrieval, context build, trusted state, audit-query) but explicitly deferred capture/extract/queue/ promote/reject pending "exercised workflow". That deferral ran into a bootstrap problem: real Claude Code sessions can't exercise the Phase 9 loop without a usable client surface to drive it. This commit ships the 8 missing subcommands so the next step (real validation on Dalidou) is unblocked. Bumps CLIENT_VERSION from 0.1.0 to 0.2.0 per the semver rules in llm-client-integration.md (new subcommands = minor bump). New subcommands in scripts/atocore_client.py -------------------------------------------- | Subcommand | Endpoint | |-----------------------|-------------------------------------------| | capture | POST /interactions | | extract | POST /interactions/{id}/extract | | reinforce-interaction | POST /interactions/{id}/reinforce | | list-interactions | GET /interactions | | get-interaction | GET /interactions/{id} | | queue | GET /memory?status=candidate | | promote | POST /memory/{id}/promote | | reject | POST /memory/{id}/reject | Each follows the existing client style: positional arguments with empty-string defaults for optional filters, truthy-string arguments for booleans (matching the existing refresh-project pattern), JSON output via print_json(), fail-open behavior inherited from request(). capture accepts prompt + response + project + client + session_id + reinforce as positionals, defaulting the client field to "atocore-client" when omitted so every capture from the shared client is identifiable in the interactions audit trail. extract defaults to preview mode (persist=false). Pass "true" as the second positional to create candidate memories. list-interactions and queue build URL query strings with url-encoded values and always include the limit, matching how the existing context-build subcommand handles its parameters. Security fix: ID-field URL encoding ----------------------------------- The initial draft used urllib.parse.quote() with the default safe set, which does NOT encode "/" because it's a reserved path character. That's a security footgun on ID fields: passing "promote mem/evil/action" would build /memory/mem/evil/action/promote and hit a completely different endpoint than intended. Fixed by passing safe="" to urllib.parse.quote() on every ID field (interaction_id and memory_id). The tests cover this explicitly via test_extract_url_encodes_interaction_id and test_promote_url_encodes_memory_id, both of which would have failed with the default behavior. Project names keep the default quote behavior because a project name with a slash would already be broken elsewhere in the system (ingest root resolution, file paths, etc). tests/test_atocore_client.py (new, 18 tests, all green) ------------------------------------------------------- A dedicated test file for the shared client that mocks the request() helper and verifies each subcommand: - calls the correct HTTP method and path - builds the correct JSON body (or query string) - passes the right subset of CLI arguments through - URL-encodes ID fields so path traversal isn't possible Tests are structured as unit tests (not integration tests) because the API surface on the server side already has its own route tests in test_api_storage.py and the Phase 9 specific files. These tests are the wiring contract between CLI args and HTTP calls. Test file highlights: - capture: default values, custom client, reinforce=false - extract: preview by default, persist=true opt-in, URL encoding - reinforce-interaction: correct path construction - list-interactions: no filters, single filter, full filter set (including ISO 8601 since parameter with T separator and Z) - get-interaction: fetch by id - queue: always filters status=candidate, accepts memory_type and project, coerces limit to int - promote / reject: correct path + URL encoding - test_phase9_full_loop_via_client_shape: end-to-end sequence that drives capture -> extract preview -> extract persist -> queue list -> promote -> reject through the shared client and verifies the exact sequence of HTTP calls that would be made These tests run in ~0.2s because they mock request() — no DB, no Chroma, no HTTP. The fast feedback loop matters because the client surface is what every agent integration eventually depends on. docs/architecture/llm-client-integration.md updates --------------------------------------------------- - New "Phase 9 reflection loop (shipped after migration safety work)" section under "What's in scope for the shared client today" with the full 8-subcommand table and a note explaining the bootstrap-problem rationale - Removed the "Memory review queue and reflection loop" section from "What's intentionally NOT in scope today"; backup admin and engineering-entity commands remain the only deferred families - Renumbered the deferred-commands list (was 3 items, now 2) - Open follow-ups updated: memory-review-subcommand item replaced with "real-usage validation of the Phase 9 loop" as the next concrete dependency - TL;DR updated to list the reflection-loop subcommands - Versioning note records the v0.1.0 -> v0.2.0 bump with the subcommands included Full suite: 215 passing (was 197), 1 warning. The +18 is tests/test_atocore_client.py. Runtime unchanged because the new tests don't touch the DB. What this commit does NOT do ---------------------------- - Does NOT change the server-side endpoints. All 8 subcommands call existing API routes that were shipped in Phase 9 Commits A/B/C. This is purely a client-side wiring commit. - Does NOT run the reflection loop against the live Dalidou instance. That's the next concrete step and is explicitly called out in the open-follow-ups section of the updated doc. - Does NOT modify the Claude Code slash command. It still pulls context only; the capture/extract/queue/promote companion commands (e.g. /atocore-record-response) are deferred until the capture workflow has been exercised in real use at least once. - Does NOT refactor the OpenClaw helper. That's a cross-repo change and remains a queued follow-up, now unblocked by the shared client having the reflection-loop subcommands.
2026-04-08 16:09:42 -04:00
"""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"),
]