feat(cli): add event commands, --json/--agent flag, agent output sanitization, and edge-case hardening for CLI SDKs (#4649)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saket Aryan
2026-04-01 21:04:59 +05:30
committed by GitHub
parent 713dba5d0a
commit 2acf9571b3
35 changed files with 2941 additions and 341 deletions
+487 -42
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import typing
from io import StringIO
from unittest.mock import patch
@@ -16,6 +17,7 @@ from mem0_cli.commands.config_cmd import (
cmd_config_show,
)
from mem0_cli.commands.entities import cmd_entities_delete, cmd_entities_list
from mem0_cli.commands.events_cmd import cmd_event_list, cmd_event_status
from mem0_cli.commands.memory import (
cmd_add,
cmd_delete,
@@ -176,30 +178,28 @@ class TestAddCommand:
def test_add_no_content_exits(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
# Patch stdin.isatty to return True so it doesn't try to read stdin
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
patch("mem0_cli.commands.memory.sys") as mock_sys,
patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False),
pytest.raises((SystemExit, ClickExit)),
):
mock_sys.stdin.isatty.return_value = True
with pytest.raises((SystemExit, ClickExit)):
cmd_add(
mock_backend,
None,
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
output="text",
)
cmd_add(
mock_backend,
None,
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
output="text",
)
def test_add_invalid_metadata_json(self, mock_backend):
console, _buf = _make_console()
@@ -279,6 +279,66 @@ class TestAddCommand:
mock_backend.add.assert_called_once()
class TestAddDeduplicatesPending:
"""Ensure duplicate PENDING entries with the same event_id are collapsed."""
DUPLICATE_PENDING: typing.ClassVar[dict] = {
"results": [
{"status": "PENDING", "event_id": "evt-dup"},
{"status": "PENDING", "event_id": "evt-dup"},
]
}
def _run_add(self, mock_backend, output):
mock_backend.add.return_value = self.DUPLICATE_PENDING
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
output=output,
)
return buf.getvalue()
def test_text_shows_one_pending(self, mock_backend):
raw = self._run_add(mock_backend, "text")
assert raw.count("Queued") == 1
def test_json_shows_one_pending(self, mock_backend):
raw = self._run_add(mock_backend, "json")
data = json.loads(raw)
results = data.get("results", data)
pending = [r for r in results if r.get("status") == "PENDING"]
assert len(pending) == 1
def test_agent_shows_one_pending(self, mock_backend):
from mem0_cli.state import set_agent_mode
set_agent_mode(True)
try:
raw = self._run_add(mock_backend, "agent")
finally:
set_agent_mode(False)
data = json.loads(raw)
assert data["count"] == 1
assert len(data["data"]) == 1
class TestSearchCommand:
def test_search_text(self, mock_backend):
console, buf = _make_console()
@@ -618,28 +678,6 @@ class TestDeleteAllCommand:
run_id="*",
)
def test_delete_all_project_wide_dry_run(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_delete_all(
mock_backend,
force=True,
all_=True,
dry_run=True,
user_id=None,
agent_id=None,
app_id=None,
run_id=None,
output="text",
)
output = buf.getvalue()
assert "project-wide" in output.lower()
mock_backend.delete.assert_not_called()
def test_delete_all_project_wide_async_response(self, mock_backend):
mock_backend.delete.return_value = {"message": "Memories deletion started..."}
console, buf = _make_console()
@@ -888,6 +926,28 @@ class TestEntitiesDeleteCommand:
output = buf.getvalue()
assert "deleted" in output.lower()
def test_delete_entity_agent_id(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.entities.console", console),
patch("mem0_cli.commands.entities.err_console", err_console),
):
cmd_entities_delete(
mock_backend,
user_id=None,
agent_id="bot1",
app_id=None,
run_id=None,
force=True,
output="text",
)
mock_backend.delete_entities.assert_called_once_with(
user_id=None, agent_id="bot1", app_id=None, run_id=None
)
output = buf.getvalue()
assert "deleted" in output.lower()
def test_delete_entity_no_id_exits(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
@@ -1024,3 +1084,388 @@ class TestEnableGraph:
)
call_kwargs = mock_backend.list_memories.call_args
assert call_kwargs.kwargs.get("enable_graph") is True
class TestEventCommands:
def test_event_list_table(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_list(mock_backend, output="table")
out = buf.getvalue()
assert "evt-abc-" in out
assert "ADD" in out
assert "SUCCEEDED" in out
def test_event_list_json(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_list(mock_backend, output="json")
out = buf.getvalue()
assert "evt-abc-123-def-456" in out
assert "evt-def-456-ghi-789" in out
def test_event_list_empty(self, mock_backend):
mock_backend.list_events.return_value = []
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_list(mock_backend, output="table")
out = buf.getvalue()
assert "No events" in out
def test_event_status_text(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text")
out = buf.getvalue()
assert "evt-abc-123-def-456" in out
assert "SUCCEEDED" in out
def test_event_status_json(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="json")
out = buf.getvalue()
assert "evt-abc-123-def-456" in out
assert "ADD" in out
class TestAgentMode:
"""Tests for --json/--agent mode: structured JSON envelope output."""
def setup_method(self):
"""Enable agent mode before each test."""
from mem0_cli.state import set_agent_mode
set_agent_mode(True)
def teardown_method(self):
"""Reset agent mode after each test."""
from mem0_cli.state import set_agent_mode
set_agent_mode(False)
# ── add ──────────────────────────────────────────────────────────────────
def test_add_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_add(
mock_backend,
"I prefer dark mode",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
output="text", # will be overridden to "agent"
)
raw = buf.getvalue()
data = json.loads(raw)
assert data["status"] == "success"
assert data["command"] == "add"
assert "data" in data
assert isinstance(data["data"], list)
assert data["count"] == 1
assert set(data["data"][0].keys()) == {"id", "memory", "event"}
def test_add_agent_mode_scope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_add(
mock_backend,
"test",
user_id="bob",
agent_id="agent1",
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
output="text",
)
data = json.loads(buf.getvalue())
assert data["scope"]["user_id"] == "bob"
assert data["scope"]["agent_id"] == "agent1"
# ── search ───────────────────────────────────────────────────────────────
def test_search_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_search(
mock_backend,
"dark mode",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
top_k=10,
threshold=0.3,
rerank=False,
keyword=False,
filter_json=None,
fields=None,
output="text",
)
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "search"
assert isinstance(data["data"], list)
assert data["count"] == 2
assert "duration_ms" in data
assert set(data["data"][0].keys()) == {"id", "memory", "score", "created_at", "categories"}
# ── list ─────────────────────────────────────────────────────────────────
def test_list_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_list(
mock_backend,
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
page=1,
page_size=100,
category=None,
after=None,
before=None,
output="table", # will be overridden to "agent"
)
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "list"
assert isinstance(data["data"], list)
assert data["count"] == 2
assert data["scope"]["user_id"] == "alice"
assert set(data["data"][0].keys()) == {"id", "memory", "created_at", "categories"}
# ── get ──────────────────────────────────────────────────────────────────
def test_get_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_get(mock_backend, "abc-123-def-456", output="text")
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "get"
assert isinstance(data["data"], dict)
assert data["data"]["id"] == "abc-123-def-456"
assert "memory" in data["data"]
assert set(data["data"].keys()) >= {"id", "memory"}
# ── update ───────────────────────────────────────────────────────────────
def test_update_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_update(mock_backend, "abc-123", "Updated content", metadata=None, output="text")
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "update"
assert isinstance(data["data"], dict)
assert "memory" in data["data"]
assert "duration_ms" in data
# ── delete ───────────────────────────────────────────────────────────────
def test_delete_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_delete(mock_backend, "abc-123-def-456", output="text")
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "delete"
assert data["data"]["id"] == "abc-123-def-456"
assert data["data"]["deleted"] is True
assert "duration_ms" in data
# ── event list ───────────────────────────────────────────────────────────
def test_event_list_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_list(mock_backend, output="table")
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "event list"
assert isinstance(data["data"], list)
assert data["count"] == 2
assert "duration_ms" in data
assert set(data["data"][0].keys()) == {
"id",
"event_type",
"status",
"latency",
"created_at",
}
# ── event status ─────────────────────────────────────────────────────────
def test_event_status_agent_mode_envelope(self, mock_backend):
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.events_cmd.console", console),
patch("mem0_cli.commands.events_cmd.err_console", err_console),
):
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text")
data = json.loads(buf.getvalue())
assert data["status"] == "success"
assert data["command"] == "event status"
assert isinstance(data["data"], dict)
assert data["data"]["id"] == "evt-abc-123-def-456"
assert "duration_ms" in data
assert set(data["data"]["results"][0].keys()) == {"id", "event", "user_id", "memory"}
assert "data" not in data["data"]["results"][0]
# ── error handling ───────────────────────────────────────────────────────
def test_error_in_agent_mode_produces_json_to_stdout(self, mock_backend):
"""Errors in agent mode must emit a JSON envelope to stdout, not stderr."""
from io import StringIO
mock_backend.get.side_effect = Exception("Memory not found")
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
captured_stdout = StringIO()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
patch("sys.stdout", captured_stdout),
pytest.raises((SystemExit, ClickExit)),
):
cmd_get(mock_backend, "bad-id", output="text")
stdout_output = captured_stdout.getvalue()
# The error JSON envelope must be on stdout
error_data = json.loads(stdout_output)
assert error_data["status"] == "error"
assert "error" in error_data
assert error_data["data"] is None
def test_branding_suppressed_in_agent_mode(self, mock_backend):
"""Scope line and success message must be absent in agent mode output."""
console, buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_add(
mock_backend,
"branding test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
output="text",
)
output = buf.getvalue()
# Must be valid JSON only — no human-readable branding
data = json.loads(output)
assert data["status"] == "success"
# "Scope:" and "Memory processed" must NOT appear in the raw output
assert "Scope:" not in output
assert "Memory processed" not in output
assert "spinner" not in output.lower()
def test_no_spinner_in_agent_mode(self, mock_backend):
"""timed_status must not emit spinner output in agent mode."""
err_buf = StringIO()
err_console_buf = Console(file=err_buf, force_terminal=False, no_color=True, width=120)
console, _buf = _make_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console_buf),
):
cmd_search(
mock_backend,
"query",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
top_k=5,
threshold=0.3,
rerank=False,
keyword=False,
filter_json=None,
fields=None,
output="text",
)
# The err_buf captures what would have been spinner/timing noise
# In agent mode it should be empty (no status lines printed)
err_output = err_buf.getvalue()
assert "Searching" not in err_output