Files
pocketpaw/scripts/smoke_agent_dispatch.py
Rohit Kushwaha eb35d696da feat(ee-cloud): MongoDB memory backend + unified chat persistence
Replaces the file-backed pocket session store with a MongoDB-backed
MemoryStoreProtocol implementation that becomes the default memory
backend for ee deployments. Unifies chat persistence so user, agent,
pocket, group, and DM messages all land in the `messages` collection
keyed by either `session_key` (pocket / agent memory) or `group`
(group / DM chat).

Schema
- Session and Message gain a `context_type` discriminator
  ("pocket" | "group") with a model_validator that enforces field
  presence rules. Existing constructors that omit context_type still
  work via a small inference shim.
- `messages` and `memory_facts` collections back the MemoryStoreProtocol;
  facts (LONG_TERM / DAILY) live separately from chat rows.

ee bootstrap
- `register_default_backend()` is called from `init_cloud_db` and swaps
  the existing MemoryManager singleton's `._store` to MongoMemoryStore
  in place, so cached references on `agent_loop.memory` and the chat
  persistence bridge automatically pick up MongoDB instead of the file
  store. POCKETPAW_MEMORY_BACKEND=file overrides the flip.
- `create_memory_store("mongodb")` lazy-imports MongoMemoryStore so OSS
  installs don't pull in motor/beanie.

Chat persistence
- `chat_persistence.save_user_message` and `_on_outbound_message` now
  write pocket-context messages keyed by the API session id (the WS
  chat_id IS the Session.sessionId). The fake "PocketPaw Chat" group
  auto-creation hack is gone.
- MongoMemoryStore normalises bus-style session keys ("websocket:X") to
  UI-style ("websocket_X") on every read/write so chat_stream's agent
  loop writes line up with `Session.sessionId` for history reads.
- `MongoMemoryStore.save` derives `sender_type` from `role` so assistant
  messages don't get persisted as `sender_type="user"`.

Sessions API
- `SessionService.get_history` reads from the unified messages
  collection (no file-memory fallback) for both pocket and group
  contexts.
- `sessions/router.get_session_history` delegates to the service.

Agent dispatch
- `_should_agent_respond` precedence rewritten: silent always opts out;
  if any agent is mentioned, only mentioned agents reply (regardless of
  mode); otherwise fall back to per-agent mode. Fixes the case where two
  `auto` agents both replied when the user mentioned one.

Observability
- Replaced silent `logger.debug` swallows in chat_persistence and
  websocket_adapter with `logger.exception` / `logger.warning` so
  persistence failures surface in dev.

Tests
- New tests/cloud/memory/ suite (~50 tests): protocol conformance,
  discriminator validators, session/facts paths, backend selection,
  OSS isolation.
- New tests/cloud/test_api_contracts.py snapshots the response shapes
  for chat/groups and sessions endpoints so the schema rewrite is a
  storage-only change for clients.
- New scripts/smoke_*.py and scripts/diag_*.py exercise each end-to-end
  path against a real local MongoDB.

Spec
- SPEC.md captures the design decisions, the clean-slate assumption
  (no migration), and the v1 scope (no workspace scoping on memory_facts,
  no vector search).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:54:42 +05:30

76 lines
2.7 KiB
Python

"""Smoke test — agent dispatch in group chat with mentions.
Verifies _should_agent_respond logic directly (avoids spinning up the agent
pool / LLM) for the matrix:
| mode | mention X | mention Y | none |
|--------------|-----------|-----------|------|
| silent | False | False | False|
| auto (X) | True | False | True |
| auto (Y) | False | True | True |
| mention_only | True (X) | True (Y) | False|
"""
from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass
@dataclass
class FakeGroupAgent:
agent: str
respond_mode: str
def _mention(agent_id: str) -> dict:
return {"type": "agent", "id": agent_id, "display_name": f"@{agent_id}"}
async def main() -> int:
from ee.cloud.shared.agent_bridge import _should_agent_respond
AX = FakeGroupAgent("agent-x", "auto")
AY = FakeGroupAgent("agent-y", "auto")
MX = FakeGroupAgent("agent-x", "mention_only")
SY = FakeGroupAgent("agent-y", "silent")
cases: list[tuple[str, FakeGroupAgent, list[dict], bool]] = [
# --- the bug we fixed: two auto agents, only the mentioned one responds ---
("two-auto-mention-X / X", AX, [_mention("agent-x")], True),
("two-auto-mention-X / Y", AY, [_mention("agent-x")], False),
("two-auto-mention-Y / X", AX, [_mention("agent-y")], False),
("two-auto-mention-Y / Y", AY, [_mention("agent-y")], True),
# --- broadcast (no mentions): both auto agents respond ---
("two-auto-no-mention / X", AX, [], True),
("two-auto-no-mention / Y", AY, [], True),
# --- mention_only ---
("mention_only-mentioned", MX, [_mention("agent-x")], True),
("mention_only-not-mentioned", MX, [_mention("agent-y")], False),
("mention_only-no-mention", MX, [], False),
# --- silent always opts out, even when mentioned ---
("silent-mentioned", SY, [_mention("agent-y")], False),
("silent-no-mention", SY, [], False),
# --- non-agent mentions (user mentions) don't gate agent dispatch ---
("auto-only-user-mentions / X", AX, [{"type": "user", "id": "u1"}], True),
]
failures: list[str] = []
for name, ga, mentions, expected in cases:
actual = await _should_agent_respond(ga, "hello", mentions)
status = "OK" if actual == expected else "FAIL"
print(f" [{status}] {name}: expected={expected} actual={actual}")
if actual != expected:
failures.append(name)
if failures:
print(f"\n{len(failures)} FAIL(s): {failures}")
return 1
print("\nSMOKE OK")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))