Files
Octop/tests/unit/proactive/test_proactive_service.py
薄生 dca16ccc21 Feat/postgresql dual backend (#60)
* feat: PostgreSQL dual-backend for control plane

Add SqlitePool|PostgresPool behind DatabasePool, parallel PG migrations,
setup wizard defer/bind, pg_dump backups with cross-engine refusal, and
memory defaulting to the control-plane DSN when using PostgreSQL.

* fix(chat): delete checkpoint data when a conversation thread is deleted

Deleting a thread only removed Octop's own metadata row; the actual
message history stayed in the agent's LangGraph checkpointer forever,
making "delete conversation" cosmetic. Also confirm before deleting a
session in the UI, since the action is now genuinely destructive.

Checkpoint deletion runs before the metadata row is removed: if it
fails outright (not just "nothing to delete"), the thread stays
visible/retryable instead of orphaning data with no remaining handle.

* fix(memory): stop suggesting pg_dump for portable export of postgres memory

The 501 hint told users to "use pg_dump on the memory schema instead".
That advice predates the shared-table layout: memory for ALL agents now
lives in one harness_memory schema isolated by a namespace column, so a
schema-level pg_dump would export every agent's memory, not just the
requester's — a cross-agent data exposure if followed on a multi-user
install.

Document the two migration models where the refusal is implemented:
sqlite memory is per-agent files moved via pack/adopt; postgres memory
is shared, and the supported way to use it from another host (e.g.
OpenClaw's harness-memory bridge with --backend postgres) is pointing
that host at the same DSN and namespace — shared, not migrated.

* fix(test): update test_slash_compact to renamed SqlitePool

The /compact test module still imported the pre-rename DBPool, which
broke collection (ImportError) on this branch after develop's compact
feature was merged in — DBPool was renamed to SqlitePool (the concrete
class) with DatabasePool now the Protocol.

DBPool -> SqlitePool at all three sites: the import, the _agent_manager
type hint, and the fixture's DBPool(path) instantiation. SqlitePool is
the byte-for-byte successor of DBPool and matches how every other test
constructs a pool; DatabasePool cannot be used at the instantiation
site (a Protocol is not instantiable).

pytest -m live now collects cleanly (31 selected, 0 errors);
test_slash_compact 3 tests pass.

* fix(test): make memory_backend db_path assertion separator-agnostic

test_explicit_sqlite_overrides_postgresql_control_plane compared the
resolved db_path against the literal "/tmp/ws/memory.sqlite", which the
code never produces on Windows: it builds the path with pathlib, so the
Windows CI job got "\tmp\ws\memory.sqlite" and the string equality failed.

Compare as Path objects so the assertion holds on every OS separator,
matching the .as_posix() discipline already used in test_config.py.
2026-07-24 14:45:53 +08:00

302 lines
9.9 KiB
Python

"""Unit tests for ProactiveCareService.
Coverage:
- successful push flow with episodes, LLM success, push success, and dedup record writes
- skip push when episodes are empty
- skip push and dedup writes when the LLM call fails
- skip dedup writes when push fails
- truncate long care messages to 200 characters
- use the default prompt when SOUL.md cannot be read without interrupting the flow
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from octop.infra.db.migrate import run_migrations
from octop.infra.db.pool import SqlitePool
from octop.infra.db.repos.agents import AgentRepo
from octop.infra.db.repos.care_push import CarePushRepo
from octop.infra.db.repos.users import UserRepo
from octop.infra.proactive.service import ProactiveCareService
from octop.infra.utils.ulid import new_ulid
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def db(tmp_path: Path) -> SqlitePool:
pool = SqlitePool(tmp_path / "test.db")
run_migrations(pool)
return pool
@pytest.fixture
def agent_id(db: SqlitePool) -> str:
uid = UserRepo(db).create(username="alice", password_hash="h", role="admin")
aid = new_ulid()
AgentRepo(db).create(agent_id=aid, user_id=uid, name="bot")
return aid
@pytest.fixture
def care_push_repo(db: SqlitePool) -> CarePushRepo:
return CarePushRepo(db)
def _make_episode(
ep_id: str,
*,
emotion: str = "sad",
intensity: int = 4,
people: list[str] | None = None,
days_ago: float = 1.0,
) -> MagicMock:
now = datetime.now(UTC)
ep = MagicMock()
ep.id = ep_id
ep.emotion = emotion
ep.intensity = intensity
ep.people = people or ["老婆"]
ep.topics = ["家庭"]
ep.occurred_at = now - timedelta(days=days_ago)
ep.summary = f"用户发生了 {ep_id} 事件"
ep.verbatim_quote = f"原话 {ep_id}"
return ep
def _make_agent(
episodes: list,
llm_response: str = "最近辛苦了,注意休息!",
soul_md: str = "",
llm_raises: Exception | None = None,
) -> MagicMock:
"""Create a mock HarnessAgent."""
agent = MagicMock()
# Mock memory.
memory = MagicMock()
memory.list_episodes.return_value = episodes
agent.memory = memory
# Mock workspace.
workspace = MagicMock()
workspace.aread_text = AsyncMock(return_value=soul_md)
agent.workspace = workspace
# Mock auxiliary LLM.
llm = AsyncMock()
if llm_raises:
llm.ainvoke = AsyncMock(side_effect=llm_raises)
else:
llm_msg = MagicMock()
llm_msg.content = llm_response
llm.ainvoke = AsyncMock(return_value=llm_msg)
model_factory = MagicMock()
model_factory.get = MagicMock(return_value=llm)
agent.model_factory = model_factory
# Mock config.
config = MagicMock()
config.memory_aux_light_model = "openai/gpt-4o-mini"
config.default_model = "openai/gpt-4o-mini"
config.locale = "zh-CN"
agent.config = config
return agent
def _make_gateway(push_success: bool = True) -> MagicMock:
"""Create a mock Gateway."""
gateway = MagicMock()
if push_success:
gateway.push_text_from_session = AsyncMock(return_value=None)
else:
gateway.push_text_from_session = AsyncMock(side_effect=RuntimeError("推送失败:网络错误"))
return gateway
def _make_agent_manager(agent: MagicMock) -> MagicMock:
mgr = MagicMock()
mgr.get_agent.return_value = agent
return mgr
def _make_service(
agent: MagicMock,
care_push_repo: CarePushRepo,
push_success: bool = True,
) -> ProactiveCareService:
gateway = _make_gateway(push_success=push_success)
agent_manager = _make_agent_manager(agent)
return ProactiveCareService(
gateway=gateway,
care_push_repo=care_push_repo,
agent_manager=agent_manager,
)
# ---------------------------------------------------------------------------
# Test: successful push flow
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_success(agent_id: str, care_push_repo: CarePushRepo):
"""Successful flow: episodes exist, LLM succeeds, push succeeds, dedup records are written."""
episodes = [_make_episode("ep1"), _make_episode("ep2", people=["老板"])]
agent = _make_agent(episodes, llm_response="最近辛苦了,注意休息!")
service = _make_service(agent, care_push_repo, push_success=True)
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
# Verify dedup records were written.
pushed = care_push_repo.list_pushed_episode_ids(agent_id)
assert len(pushed) > 0
agent.model_factory.get.assert_called_once()
# ---------------------------------------------------------------------------
# Test: skip push when episodes are empty
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_no_episodes(agent_id: str, care_push_repo: CarePushRepo):
"""Should skip push, LLM calls, and dedup writes when there are no episodes."""
agent = _make_agent([])
service = _make_service(agent, care_push_repo)
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
# Verify the LLM was not called.
agent.get_aux_llm.assert_not_called()
# Verify no dedup records were written.
pushed = care_push_repo.list_pushed_episode_ids(agent_id)
assert len(pushed) == 0
# ---------------------------------------------------------------------------
# Test: LLM call failure
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_llm_failure(agent_id: str, care_push_repo: CarePushRepo):
"""Should not push, write dedup records, or raise when the LLM call fails."""
episodes = [_make_episode("ep1")]
agent = _make_agent(episodes, llm_raises=RuntimeError("LLM 服务不可用"))
service = _make_service(agent, care_push_repo)
# Should not raise.
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
# Verify no dedup records were written.
pushed = care_push_repo.list_pushed_episode_ids(agent_id)
assert len(pushed) == 0
# ---------------------------------------------------------------------------
# Test: push failure
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_push_failure(agent_id: str, care_push_repo: CarePushRepo):
"""Should not write dedup records or raise when push fails."""
episodes = [_make_episode("ep1")]
agent = _make_agent(episodes)
service = _make_service(agent, care_push_repo, push_success=False)
# Should not raise.
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
# Verify no dedup records were written.
pushed = care_push_repo.list_pushed_episode_ids(agent_id)
assert len(pushed) == 0
# ---------------------------------------------------------------------------
# Test: long care-message truncation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_truncates_long_text(agent_id: str, care_push_repo: CarePushRepo):
"""Care messages longer than 200 characters should be truncated."""
long_text = "关心" * 200 # 400 CJK characters.
episodes = [_make_episode("ep1")]
agent = _make_agent(episodes, llm_response=long_text)
# Capture the actual pushed text.
pushed_texts: list[str] = []
async def _capture_gen(agent_id: str, session_key: str, text: str, **kwargs):
pushed_texts.append(text)
gateway = MagicMock()
gateway.push_text_from_session = _capture_gen
agent_manager = _make_agent_manager(agent)
service = ProactiveCareService(
gateway=gateway,
care_push_repo=care_push_repo,
agent_manager=agent_manager,
)
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
assert len(pushed_texts) == 1
assert len(pushed_texts[0]) <= 200
# ---------------------------------------------------------------------------
# Test: use the default prompt when SOUL.md cannot be read
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_soul_md_failure_uses_default_prompt(
agent_id: str, care_push_repo: CarePushRepo
):
"""Should use the default prompt and continue when SOUL.md cannot be read."""
episodes = [_make_episode("ep1")]
agent = _make_agent(episodes)
# Make workspace.aread_text raise.
agent.workspace.aread_text = AsyncMock(side_effect=FileNotFoundError("SOUL.md not found"))
service = _make_service(agent, care_push_repo, push_success=True)
# Should not raise, and push should complete normally.
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
# Verify dedup records were written, which means push succeeded.
pushed = care_push_repo.list_pushed_episode_ids(agent_id)
assert len(pushed) > 0
# ---------------------------------------------------------------------------
# Test: skip when memory is disabled
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_run_no_memory(agent_id: str, care_push_repo: CarePushRepo):
"""Should skip push without raising when memory is disabled."""
agent = MagicMock()
agent.memory = None # Memory is disabled.
agent_manager = _make_agent_manager(agent)
gateway = _make_gateway()
service = ProactiveCareService(
gateway=gateway,
care_push_repo=care_push_repo,
agent_manager=agent_manager,
)
# Should not raise.
await service.run(agent_id, f"{agent_id}:wxwork:user123:dm")
# Verify no dedup records were written.
pushed = care_push_repo.list_pushed_episode_ids(agent_id)
assert len(pushed) == 0