Files
Octop/tests/integration/test_memory_api.py
2209cdc90f chore(deps): switch runtime packages to octop-* 1.0.0 (#1136)
Replace orcakit-harness-agent / harness-* with octop-harness, octop-gateway, octop-memory, and octop-browser, and align docs, UI copy, and generated paths.

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 06:44:24 +08:00

471 lines
15 KiB
Python

"""tests/integration/test_memory_api.py — dashboard memory router.
End-to-end smoke: build a real OctopServer with a main agent, seed a
small graph (entity / candidate / atom / episode / journal) directly
into the agent memory SQLite (``.octop/memory.sqlite`` for new agents)
via ``octop_memory.Memory``, then drive the FastAPI router and assert
the JSON shapes.
We bypass the octop-harness middleware (``capture`` / ``extract``)
because the dashboard surface is purely read-side; this keeps the
test fast and deterministic without an LLM.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
# `octop_memory` is an optional dependency (lazy-imported by the app); the
# real package is not part of the base install, so skip these integration
# tests when it is unavailable rather than failing the whole suite. The app
# reaches memory through the JSON-RPC bridge, so gate on that exact import
# target (the old ``octop_memory.lightclaw`` name no longer exists and made
# this whole file silently skip).
pytest.importorskip("octop_memory.adapters.bridge.handlers")
from octop.api.common.memory_client import (
invalidate_cached_memory,
memory_db_path_for_cfg,
memory_namespace,
)
def _now() -> datetime:
return datetime.now(UTC)
def _seed_memory(srv: Any, agent_id: str) -> None:
"""Populate the agent's memory.sqlite with one row per layer."""
from octop_memory.core import Memory # noqa: PLC0415
from octop_memory.types import ( # noqa: PLC0415
AtomCard,
Candidate,
Entity,
Episode,
JournalEntry,
RawEvent,
)
workspace = srv.app_runtime.agent_registry.resolve_workspace_dir(agent_id)
row = srv.services.agent_repo.get(agent_id)
cfg: dict[str, Any] = {}
if row is not None and row.config_json:
import json # noqa: PLC0415
try:
parsed = json.loads(row.config_json)
if isinstance(parsed, dict):
cfg = parsed
except json.JSONDecodeError:
cfg = {}
db_path = memory_db_path_for_cfg(workspace, cfg)
db_path.parent.mkdir(parents=True, exist_ok=True)
namespace = memory_namespace(agent_id)
memory = Memory(
namespace=namespace,
backend="sqlite",
backend_config={"db_path": str(db_path)},
)
entity = Entity(
id="ent-user",
entity_type="User",
canonical_name="User",
aliases=[],
atom_count=0,
created_at=_now(),
)
memory.add_entity(entity)
candidate = Candidate(
id="cand-1",
raw_event_ids=[],
candidate_type="Preference",
status="promoted",
title="美式咖啡偏好",
assertion="喜欢喝美式咖啡",
verbatim_quote="喜欢喝美式咖啡",
quote_event_id="",
subject_name="user",
subject_entity_type="User",
target_entity_id="ent-user",
confidence="high",
importance="high",
recommended_action="promote",
promotion_reason="seed",
extractor_version="test",
created_at=_now(),
)
memory.add_candidate(candidate)
atom = AtomCard(
id="atom-1",
entity_id="ent-user",
candidate_id="cand-1",
raw_event_ids=[],
assertion="喜欢喝美式咖啡",
verbatim_quote="喜欢喝美式咖啡",
quote_event_id="",
search_terms=["coffee", "americano"],
occurred_at=_now(),
confidence="high",
importance="high",
created_at=_now(),
)
memory.add_atom(atom)
memory.add_raw_batch(
[
RawEvent(
id="raw-1",
host="octop",
session_id="sess-1",
thread_id="th-1",
user="u",
timestamp=_now(),
event_type="user_message",
content="我喜欢喝美式咖啡",
),
RawEvent(
id="raw-2",
host="octop",
session_id="sess-1",
thread_id="th-1",
user="u",
timestamp=_now(),
event_type="assistant_message",
content="好的,记住了",
),
]
)
episode = Episode(
id="ep-1",
raw_event_ids=[],
occurred_at=_now(),
summary="用户表达对美式咖啡的偏好",
verbatim_quote="喜欢喝美式咖啡",
quote_event_id="",
emotion="happy",
intensity=2,
people=[],
topics=["饮品"],
extractor_version="test",
created_at=_now(),
)
memory.add_episodes([episode])
memory.append_journal(
JournalEntry(
id="j-1",
timestamp=_now(),
action="promote",
actor="auto",
target_entity_id="ent-user",
target_atom_id="atom-1",
target_candidate_id="cand-1",
note="seed",
)
)
# Drop any cached memory in the dashboard cache so the router sees
# the fresh seed rows on its first request.
invalidate_cached_memory(agent_id)
@pytest.mark.asyncio
async def test_stats_counts(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.get(f"/api/agents/{aid}/memory/stats/counts", headers=auth)
assert r.status_code == 200, r.text
body = r.json()
assert body["atoms"] == 1
assert body["entities"] == 1
assert body["episodes"] == 1
assert body["candidates_pending"] == 0
assert body["atoms_delta_7d"] == 1
@pytest.mark.asyncio
async def test_list_atoms_returns_kind(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.post(f"/api/agents/{aid}/memory/atoms/list", headers=auth, json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["total"] == 1
item = body["items"][0]
assert item["id"] == "atom-1"
assert item["kind"] == "Preference"
@pytest.mark.asyncio
async def test_get_atom_inline_kind(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.get(f"/api/agents/{aid}/memory/atoms/atom-1", headers=auth)
assert r.status_code == 200, r.text
body = r.json()
assert body["assertion"] == "喜欢喝美式咖啡"
assert body["kind"] == "Preference"
@pytest.mark.asyncio
async def test_get_atom_404(env_with_main_agent) -> None:
client, _srv, auth, aid = env_with_main_agent
r = await client.get(f"/api/agents/{aid}/memory/atoms/no-such-id", headers=auth)
assert r.status_code == 404
@pytest.mark.asyncio
async def test_terminal_about_me(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.get(f"/api/agents/{aid}/memory/terminal/about_me", headers=auth)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
assert items[0]["assertion"] == "喜欢喝美式咖啡"
assert items[0]["kind"] == "Preference"
@pytest.mark.asyncio
async def test_recent_journal(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.get(f"/api/agents/{aid}/memory/journal/recent?limit=5", headers=auth)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
assert items[0]["action"] == "promote"
@pytest.mark.asyncio
async def test_deprecate_atom_round_trip(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.post(
f"/api/agents/{aid}/memory/atoms/atom-1:deprecate",
headers=auth,
json={"reason": "outdated"},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "deprecated"
# A second deprecate of the same atom returns 404 (already deprecated).
r2 = await client.post(
f"/api/agents/{aid}/memory/atoms/atom-1:deprecate",
headers=auth,
json={"reason": "again"},
)
assert r2.status_code == 404
@pytest.mark.asyncio
async def test_create_and_replace_atom_round_trip(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
created = await client.post(
f"/api/agents/{aid}/memory/atoms",
headers=auth,
json={
"assertion": "喜欢早起跑步",
"entity_name": "作息",
"entity_type": "Fact",
"kind": "Preference",
"actor": "rule",
},
)
assert created.status_code == 200, created.text
body = created.json()
assert body["status"] == "created"
assert body["created_entity"] is True
atom_id = body["atom"]["id"]
assert body["atom"]["assertion"] == "喜欢早起跑步"
create_journal = await client.post(
f"/api/agents/{aid}/memory/journal/list",
headers=auth,
json={"action": "create", "target_atom_id": atom_id},
)
assert create_journal.status_code == 200
assert create_journal.json()["items"][0]["actor"] == "user"
replaced = await client.post(
f"/api/agents/{aid}/memory/atoms/{atom_id}:replace",
headers=auth,
json={"assertion": "改成晚上跑步", "actor": "auto"},
)
assert replaced.status_code == 200, replaced.text
assert replaced.json()["status"] == "replaced"
assert replaced.json()["atom"]["assertion"] == "改成晚上跑步"
assert replaced.json()["old_atom_id"] == atom_id
replacement_id = replaced.json()["atom"]["id"]
edit_journal = await client.post(
f"/api/agents/{aid}/memory/journal/list",
headers=auth,
json={"action": "user_edit", "target_atom_id": replacement_id},
)
assert edit_journal.status_code == 200
assert edit_journal.json()["items"][0]["actor"] == "user"
again = await client.post(
f"/api/agents/{aid}/memory/atoms/{atom_id}:replace",
headers=auth,
json={"assertion": "这条已经弃用了"},
)
assert again.status_code == 404
@pytest.mark.asyncio
async def test_list_raw_events(env_with_main_agent) -> None:
client, srv, auth, aid = env_with_main_agent
_seed_memory(srv, aid)
r = await client.post(f"/api/agents/{aid}/memory/raw_events/list", headers=auth, json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["total"] == 2
assert {i["id"] for i in body["items"]} == {"raw-1", "raw-2"}
# event_type filter narrows to the user message.
r2 = await client.post(
f"/api/agents/{aid}/memory/raw_events/list",
headers=auth,
json={"event_type": "user_message"},
)
assert r2.status_code == 200, r2.text
assert r2.json()["total"] == 1
assert r2.json()["items"][0]["id"] == "raw-1"
@pytest.mark.asyncio
async def test_extract_config_defaults_then_update(env_with_main_agent) -> None:
client, _srv, auth, aid = env_with_main_agent
# GET returns defaults for a fresh agent.
r = await client.get(f"/api/agents/{aid}/memory/extract-config", headers=auth)
assert r.status_code == 200, r.text
assert r.json()["memory_enabled"] is True
assert r.json()["extract_trigger_mode"] == "idle"
assert r.json()["extract_idle_seconds"] == 300.0
# PUT switches to interval mode; short interval is clamped to the floor.
r2 = await client.put(
f"/api/agents/{aid}/memory/extract-config",
headers=auth,
json={
"memory_enabled": False,
"extract_trigger_mode": "interval",
"extract_interval_seconds": 5,
},
)
assert r2.status_code == 200, r2.text
assert r2.json()["memory_enabled"] is False
assert r2.json()["extract_trigger_mode"] == "interval"
assert r2.json()["extract_interval_seconds"] == 300.0 # clamped to 5-min floor
# GET reflects the persisted change.
r3 = await client.get(f"/api/agents/{aid}/memory/extract-config", headers=auth)
assert r3.json()["extract_trigger_mode"] == "interval"
assert r3.json()["extract_interval_seconds"] == 300.0
assert r3.json()["memory_enabled"] is False
@pytest.mark.asyncio
async def test_extract_config_aux_model_roundtrip(env_with_main_agent) -> None:
from tests.support.auth import create_provider
client, _srv, auth, aid = env_with_main_agent
await create_provider(
client,
auth,
name="aux-prov",
base_url="https://api.example.com/v1",
models=[{"id": "mini", "name": "Mini", "enabled": True}],
)
# Fresh agent: AUTO (null).
r = await client.get(f"/api/agents/{aid}/memory/extract-config", headers=auth)
assert r.status_code == 200, r.text
assert r.json()["aux_model"] is None
# Pin a usable ref.
r2 = await client.put(
f"/api/agents/{aid}/memory/extract-config",
headers=auth,
json={"aux_model": "aux-prov/mini"},
)
assert r2.status_code == 200, r2.text
assert r2.json()["aux_model"] == "aux-prov/mini"
# Persisted; other fields untouched.
r3 = await client.get(f"/api/agents/{aid}/memory/extract-config", headers=auth)
assert r3.json()["aux_model"] == "aux-prov/mini"
assert r3.json()["extract_trigger_mode"] == "idle"
# Empty string resets back to AUTO.
r4 = await client.put(
f"/api/agents/{aid}/memory/extract-config",
headers=auth,
json={"aux_model": ""},
)
assert r4.status_code == 200, r4.text
assert r4.json()["aux_model"] is None
@pytest.mark.asyncio
async def test_extract_config_rejects_unusable_aux_model(env_with_main_agent) -> None:
client, _srv, auth, aid = env_with_main_agent
r = await client.put(
f"/api/agents/{aid}/memory/extract-config",
headers=auth,
json={"aux_model": "no-such-provider/model"},
)
assert r.status_code == 400, r.text
@pytest.mark.asyncio
async def test_extract_config_rejects_bad_mode(env_with_main_agent) -> None:
client, _srv, auth, aid = env_with_main_agent
r = await client.put(
f"/api/agents/{aid}/memory/extract-config",
headers=auth,
json={"extract_trigger_mode": "bogus"},
)
assert r.status_code == 400, r.text
@pytest.mark.asyncio
async def test_extract_config_clamps_zero_idle_time_to_one_minute(
env_with_main_agent,
) -> None:
client, _srv, auth, aid = env_with_main_agent
r = await client.put(
f"/api/agents/{aid}/memory/extract-config",
headers=auth,
json={"extract_trigger_mode": "idle", "extract_idle_seconds": 0},
)
assert r.status_code == 200, r.text
assert r.json()["extract_idle_seconds"] == 60.0
@pytest.mark.asyncio
async def test_unauthenticated(env_with_main_agent) -> None:
client, _srv, _auth, aid = env_with_main_agent
# No auth headers → must be rejected by the auth middleware.
r = await client.get(f"/api/agents/{aid}/memory/stats/counts")
assert r.status_code in (401, 403)