feat(cli): Agent Mode bootstrap + claim flow (Python + Node) (#5123)
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
|
||||
|
||||
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
|
||||
sync so that the Python and Node CLIs expose an identical surface for the
|
||||
Agent Mode entrypoint. If you add a flag here, add the same assertion on
|
||||
the Node side (and vice versa).
|
||||
|
||||
Network-bound bootstrap is covered by the platform-side E2E suite
|
||||
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
|
||||
the CLI surface that ships in the binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
|
||||
env = os.environ.copy()
|
||||
for key in list(env.keys()):
|
||||
if key.startswith("MEM0_"):
|
||||
del env[key]
|
||||
env.pop("FORCE_COLOR", None)
|
||||
if home_dir:
|
||||
env["HOME"] = home_dir
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=15,
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=result.args,
|
||||
returncode=result.returncode,
|
||||
stdout=_strip_ansi(result.stdout),
|
||||
stderr=_strip_ansi(result.stderr),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_home(tmp_path):
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
class TestInitFlagSurface:
|
||||
"""`mem0 init --help` must expose the Agent Mode flags."""
|
||||
|
||||
def test_init_help_lists_agent_flag(self):
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--agent" in result.stdout
|
||||
|
||||
def test_init_help_describes_agent_mode(self):
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
# Description must mention what --agent actually does so an agent
|
||||
# reading the help can self-discover the bootstrap entrypoint.
|
||||
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
|
||||
|
||||
def test_init_help_lists_source_flag(self):
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--source" in result.stdout
|
||||
|
||||
def test_init_help_lists_email_and_code(self):
|
||||
# Claim flow flags must remain present alongside Agent Mode flags.
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--email" in result.stdout
|
||||
assert "--code" in result.stdout
|
||||
|
||||
|
||||
class TestArgvPreprocessing:
|
||||
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
|
||||
|
||||
Regression for the bug where the top-level `--agent` JSON-alias was
|
||||
stripped from ``sys.argv`` before Typer could bind it to the init
|
||||
subcommand, making ``mem0 init --agent`` indistinguishable from a
|
||||
plain ``mem0 init`` (interactive wizard).
|
||||
"""
|
||||
|
||||
def test_init_with_agent_reaches_subcommand(self, clean_home):
|
||||
# We can't hit a real backend in unit tests, so we point the CLI at
|
||||
# a guaranteed-dead URL and assert the failure is the bootstrap
|
||||
# request failing — proving the --agent flag was honored and the
|
||||
# bootstrap branch ran, not the interactive wizard.
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
|
||||
"HOME": clean_home,
|
||||
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
|
||||
"FORCE_COLOR": "0",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
combined = _strip_ansi(result.stdout + result.stderr).lower()
|
||||
# Either we got a connection/network error from the bootstrap POST,
|
||||
# or the CLI surfaced an Agent Mode-specific failure message.
|
||||
assert (
|
||||
"agent" in combined
|
||||
or "connect" in combined
|
||||
or "network" in combined
|
||||
or "fetch" in combined
|
||||
or "bootstrap" in combined
|
||||
), f"Expected bootstrap attempt, got: {combined!r}"
|
||||
|
||||
|
||||
class TestJsonEnvelopeParity:
|
||||
"""`mem0 init --agent --json` should produce a JSON envelope on success.
|
||||
|
||||
Without a live backend we can only assert the failure shape: when the
|
||||
backend is unreachable, the CLI must still exit non-zero AND not crash
|
||||
on a Python traceback (which would mean we leaked an exception past
|
||||
the agent-mode handler).
|
||||
"""
|
||||
|
||||
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
|
||||
"HOME": clean_home,
|
||||
"MEM0_BASE_URL": "http://127.0.0.1:1",
|
||||
"FORCE_COLOR": "0",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
combined = _strip_ansi(result.stdout + result.stderr)
|
||||
assert "Traceback (most recent call last)" not in combined
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
class TestInitInCommandList:
|
||||
"""`mem0 --help` must list `init` so agents walking the top-level help
|
||||
can discover the Agent Mode entrypoint without prior knowledge."""
|
||||
|
||||
def test_top_level_help_lists_init(self):
|
||||
result = _run(["--help"])
|
||||
assert result.returncode == 0
|
||||
assert "init" in result.stdout
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Unit tests for init internals — decision tree primitives + plugin sync.
|
||||
|
||||
These tests exercise the units that the high-level subprocess parity tests in
|
||||
``test_agent_mode.py`` deliberately can't reach:
|
||||
|
||||
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
|
||||
flap silently mints a new shadow over a working key).
|
||||
- ``plugin_sync`` must only update entries that already exist, preserve
|
||||
trailing newlines, and never mangle other lines.
|
||||
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
|
||||
real cause instead of DRF's opaque "You do not have permission" string.
|
||||
|
||||
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
|
||||
behavioral assertion here, mirror it on the Node side and vice versa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mem0_cli.commands.init_cmd import _ping_key
|
||||
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
|
||||
|
||||
# ── _ping_key ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code: int) -> None:
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
|
||||
assert _ping_key("k", "http://x") is True
|
||||
|
||||
|
||||
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
|
||||
assert _ping_key("k", "http://x") is False
|
||||
|
||||
|
||||
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
|
||||
assert _ping_key("k", "http://x") is False
|
||||
|
||||
|
||||
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Transient upstream failure must NOT cause a shadow to be minted.
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
|
||||
assert _ping_key("k", "http://x") is True
|
||||
|
||||
|
||||
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
|
||||
def boom(*a, **kw):
|
||||
raise httpx.ConnectError("nope")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert _ping_key("k", "http://x") is True
|
||||
|
||||
|
||||
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(*a, **kw):
|
||||
raise httpx.ReadTimeout("slow")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert _ping_key("k", "http://x") is True
|
||||
|
||||
|
||||
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
|
||||
rc = tmp_path / ".zshrc"
|
||||
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
|
||||
changed = _update_shell_rc(rc, "newkey")
|
||||
assert changed is True
|
||||
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
|
||||
|
||||
|
||||
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
|
||||
rc = tmp_path / ".zshrc"
|
||||
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
|
||||
changed = _update_shell_rc(rc, "newkey")
|
||||
assert changed is False
|
||||
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
|
||||
|
||||
|
||||
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
|
||||
rc = tmp_path / ".zshrc"
|
||||
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
|
||||
rc.write_text(original, encoding="utf-8")
|
||||
_update_shell_rc(rc, "newkey")
|
||||
after = rc.read_text(encoding="utf-8")
|
||||
assert "alias ll='ls -la'\n" in after
|
||||
assert "export OTHER=keepme\n" in after
|
||||
assert "# my zshrc\n" in after
|
||||
assert 'export MEM0_API_KEY="newkey"\n' in after
|
||||
|
||||
|
||||
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
|
||||
rc = tmp_path / ".zshrc"
|
||||
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
|
||||
assert _update_shell_rc(rc, "same") is False
|
||||
|
||||
|
||||
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
|
||||
rc = tmp_path / ".zshrc" # does not exist
|
||||
assert _update_shell_rc(rc, "x") is False
|
||||
|
||||
|
||||
# ── plugin_sync._update_claude_settings ────────────────────────────────────
|
||||
|
||||
|
||||
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
|
||||
import json
|
||||
|
||||
settings = tmp_path / "settings.json"
|
||||
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
|
||||
changed = _update_claude_settings(settings, "newkey")
|
||||
assert changed is False
|
||||
# Original content unchanged.
|
||||
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
|
||||
|
||||
|
||||
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
|
||||
import json
|
||||
|
||||
settings = tmp_path / "settings.json"
|
||||
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
|
||||
changed = _update_claude_settings(settings, "newkey")
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
|
||||
import json
|
||||
|
||||
settings = tmp_path / "settings.json"
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
changed = _update_claude_settings(settings, "fresh")
|
||||
assert changed is True
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
assert data["env"]["MEM0_API_KEY"] == "fresh"
|
||||
assert data["env"]["OTHER"] == "y" # other keys preserved
|
||||
|
||||
|
||||
def test_claude_settings_idempotent(tmp_path) -> None:
|
||||
import json
|
||||
|
||||
settings = tmp_path / "settings.json"
|
||||
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
|
||||
assert _update_claude_settings(settings, "same") is False
|
||||
|
||||
|
||||
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
|
||||
settings = tmp_path / "settings.json"
|
||||
settings.write_text("{ this is not json", encoding="utf-8")
|
||||
assert _update_claude_settings(settings, "x") is False
|
||||
|
||||
|
||||
# ── bootstrap rate-limit translation ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
|
||||
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
|
||||
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
|
||||
from mem0_cli.config import Mem0Config
|
||||
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status_code = 403
|
||||
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
|
||||
fake_resp.json = MagicMock(
|
||||
return_value={"detail": "You do not have permission to perform this action."}
|
||||
)
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def post(self, *a, **kw):
|
||||
return fake_resp
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _Client)
|
||||
cfg = Mem0Config()
|
||||
cfg.platform.base_url = "https://api.mem0.ai"
|
||||
import typer
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
bootstrap_via_backend(cfg)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
combined = captured.out + captured.err
|
||||
assert "Daily Agent Mode signup limit reached" in combined
|
||||
assert "permission to perform this action" not in combined
|
||||
Reference in New Issue
Block a user