chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
0
tests/unit/test_memory/test_get/__init__.py
Normal file
0
tests/unit/test_memory/test_get/__init__.py
Normal file
177
tests/unit/test_memory/test_get/test_dto.py
Normal file
177
tests/unit/test_memory/test_get/test_dto.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Tests for ``memory.get.dto``.
|
||||
|
||||
Pydantic-side guarantees the manager / route can rely on:
|
||||
|
||||
* ``GetRequest`` defaults match the wiki spec (``page=1`` /
|
||||
``page_size=20`` / ``sort_by="timestamp"`` / ``sort_order="desc"``)
|
||||
* ``page_size`` upper bound (1–100)
|
||||
* ``owner_type`` × ``memory_type`` strict pairing
|
||||
* Unknown fields on the request are rejected (``extra="forbid"``)
|
||||
|
||||
Filter DSL coverage lives in ``test_memory/test_search/test_filters.py``
|
||||
since ``/get`` shares :class:`everos.memory.search.FilterNode`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from everos.memory.get.dto import (
|
||||
GetMemoryType,
|
||||
GetRequest,
|
||||
)
|
||||
|
||||
# ── GetRequest defaults / shape ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_request_defaults_match_wiki() -> None:
|
||||
"""``page`` / ``page_size`` / ``sort_by`` / ``sort_order`` come from the wiki."""
|
||||
req = GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
)
|
||||
assert req.page == 1
|
||||
assert req.page_size == 20
|
||||
assert req.sort_by == "timestamp"
|
||||
assert req.sort_order == "desc"
|
||||
assert req.filters is None
|
||||
|
||||
|
||||
def test_get_request_page_size_upper_bound() -> None:
|
||||
"""101 → ValidationError (wiki cap is 100)."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
page_size=101,
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_page_size_lower_bound() -> None:
|
||||
"""0 → ValidationError (page_size ≥ 1)."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
page_size=0,
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_page_lower_bound() -> None:
|
||||
"""0 → ValidationError (page ≥ 1; 1-indexed)."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
page=0,
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_unknown_field() -> None:
|
||||
"""``extra='forbid'`` — typos surface as a 422, not silent drops."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
unknown_extra=True, # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_empty_user_id() -> None:
|
||||
"""``user_id`` carries ``min_length=1`` — empty string is 422."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest(
|
||||
user_id="",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_missing_memory_type() -> None:
|
||||
"""``memory_type`` is required — omission is 422."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest( # type: ignore[call-arg]
|
||||
user_id="u1",
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_missing_owner_identity() -> None:
|
||||
"""Neither ``user_id`` nor ``agent_id`` → xor validator rejects."""
|
||||
with pytest.raises(ValidationError, match="exactly one of"):
|
||||
GetRequest( # type: ignore[call-arg]
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_both_user_and_agent_id() -> None:
|
||||
"""Both ``user_id`` and ``agent_id`` set → xor validator rejects."""
|
||||
with pytest.raises(ValidationError, match="exactly one of"):
|
||||
GetRequest(
|
||||
user_id="u1",
|
||||
agent_id="agent_x",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_invalid_memory_type_value() -> None:
|
||||
"""A value outside the four-kind enum is 422."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest.model_validate(
|
||||
{
|
||||
"user_id": "u1",
|
||||
"memory_type": "atomic_fact", # not a top-level kind
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get_request_rejects_invalid_sort_order() -> None:
|
||||
"""``sort_order`` is a tight Literal — typos / casing variants are 422."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest.model_validate(
|
||||
{
|
||||
"user_id": "u1",
|
||||
"memory_type": "episode",
|
||||
"sort_order": "DESC", # must be lowercase
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── owner_type × memory_type pairing ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"id_field, memory_type",
|
||||
[
|
||||
("user_id", GetMemoryType.EPISODE),
|
||||
("user_id", GetMemoryType.PROFILE),
|
||||
("agent_id", GetMemoryType.AGENT_CASE),
|
||||
("agent_id", GetMemoryType.AGENT_SKILL),
|
||||
],
|
||||
)
|
||||
def test_get_request_allows_valid_owner_memory_pair(
|
||||
id_field: str,
|
||||
memory_type: GetMemoryType,
|
||||
) -> None:
|
||||
"""The four valid (owner-kind, memory_type) combinations."""
|
||||
req = GetRequest(**{id_field: "u1"}, memory_type=memory_type)
|
||||
assert req.memory_type is memory_type
|
||||
expected_owner_type = "user" if id_field == "user_id" else "agent"
|
||||
assert req.owner_type == expected_owner_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"id_field, memory_type",
|
||||
[
|
||||
("user_id", GetMemoryType.AGENT_CASE),
|
||||
("user_id", GetMemoryType.AGENT_SKILL),
|
||||
("agent_id", GetMemoryType.EPISODE),
|
||||
("agent_id", GetMemoryType.PROFILE),
|
||||
],
|
||||
)
|
||||
def test_get_request_rejects_cross_owner_memory_pair(
|
||||
id_field: str,
|
||||
memory_type: GetMemoryType,
|
||||
) -> None:
|
||||
"""Cross-pairs (user_id+agent_case etc.) are 422 at the DTO layer."""
|
||||
with pytest.raises(ValidationError):
|
||||
GetRequest(**{id_field: "u1"}, memory_type=memory_type)
|
||||
212
tests/unit/test_memory/test_get/test_filters_adapter.py
Normal file
212
tests/unit/test_memory/test_get/test_filters_adapter.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""Tests for ``memory.get.filters_adapter.compile_filters_for_get``.
|
||||
|
||||
The adapter is a thin wrapper around
|
||||
:func:`everos.memory.search.compile_filters` — these tests pin the
|
||||
behaviour /get callers depend on:
|
||||
|
||||
* base clause shape (``owner_id = '...' AND owner_type = '...'``)
|
||||
* flat multi-field → implicit ``AND``
|
||||
* reserved field (``owner_id`` / ``owner_type`` inside ``filters``)
|
||||
→ :class:`FilterError`
|
||||
* unknown field → :class:`FilterError`
|
||||
* top-level ``AND`` / ``OR`` combinators are accepted (parity with
|
||||
``/search`` — the wiki §附录 C restriction was dropped 2026-05-16)
|
||||
* ``timestamp`` range (multi-op map) renders ``AND``-folded clauses
|
||||
* ``sender_id`` is an array column → ``array_has(...)`` rendering
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.memory.get.filters_adapter import compile_filters_for_get
|
||||
from everos.memory.search import FilterError, FilterNode
|
||||
|
||||
|
||||
def test_no_filters_emits_base_clause() -> None:
|
||||
"""``filters=None`` → owner + app/project scope clauses AND-joined."""
|
||||
where = compile_filters_for_get(None, owner_id="u1", owner_type="user")
|
||||
assert where == (
|
||||
"owner_id = 'u1' AND owner_type = 'user' "
|
||||
"AND app_id = 'default' AND project_id = 'default'"
|
||||
)
|
||||
|
||||
|
||||
def test_owner_id_quote_is_escaped() -> None:
|
||||
"""SQL-standard double-quote escape on ``owner_id``."""
|
||||
where = compile_filters_for_get(None, owner_id="o'reilly", owner_type="user")
|
||||
assert where == (
|
||||
"owner_id = 'o''reilly' AND owner_type = 'user' "
|
||||
"AND app_id = 'default' AND project_id = 'default'"
|
||||
)
|
||||
|
||||
|
||||
def test_flat_multi_field_renders_implicit_and() -> None:
|
||||
"""Multiple top-level fields → implicit ``AND`` between predicates."""
|
||||
node = FilterNode.model_validate({"session_id": "sess_a", "parent_id": "mc_x"})
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
# Field iteration order follows insertion order, so both are present.
|
||||
assert "owner_id = 'u1'" in where
|
||||
assert "owner_type = 'user'" in where
|
||||
assert "session_id = 'sess_a'" in where
|
||||
assert "parent_id = 'mc_x'" in where
|
||||
# 4 base scope clauses + 2 filter fields = 6 clauses → 5 ' AND ' joins.
|
||||
assert where.count(" AND ") == 5
|
||||
|
||||
|
||||
def test_reserved_owner_id_in_filters_raises() -> None:
|
||||
"""``owner_id`` inside ``filters`` is a hard error (must be top level)."""
|
||||
node = FilterNode.model_validate({"owner_id": "u1"})
|
||||
with pytest.raises(FilterError, match="reserved"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_reserved_owner_type_in_filters_raises() -> None:
|
||||
"""``owner_type`` inside ``filters`` is also reserved."""
|
||||
node = FilterNode.model_validate({"owner_type": "user"})
|
||||
with pytest.raises(FilterError, match="reserved"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_unsupported_field_raises() -> None:
|
||||
"""Any field outside the shared allow-list → :class:`FilterError`."""
|
||||
node = FilterNode.model_validate({"random_attr": "x"})
|
||||
with pytest.raises(FilterError, match="unsupported"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_timestamp_range_renders_and_folded() -> None:
|
||||
"""Multi-op map on one field folds with ``AND`` (reused from /search)."""
|
||||
node = FilterNode.model_validate(
|
||||
{"timestamp": {"gte": 1704067200000, "lt": 1735689600000}}
|
||||
)
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "timestamp >= TIMESTAMP '" in where
|
||||
assert "timestamp < TIMESTAMP '" in where
|
||||
# The two clauses are AND-joined inside one parenthesised group.
|
||||
assert "(timestamp >= TIMESTAMP" in where
|
||||
assert " AND timestamp < TIMESTAMP" in where
|
||||
|
||||
|
||||
def test_sender_id_in_list_renders_array_has() -> None:
|
||||
"""``sender_id`` is an array column — ``in`` → ``array_has(...) OR ...``."""
|
||||
node = FilterNode.model_validate({"sender_id": {"in": ["alice", "bob"]}})
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "array_has(sender_ids, 'alice')" in where
|
||||
assert "array_has(sender_ids, 'bob')" in where
|
||||
|
||||
|
||||
def test_sender_id_eq_shorthand_renders_array_has() -> None:
|
||||
"""Equality shorthand on an array column → single ``array_has``."""
|
||||
node = FilterNode.model_validate({"sender_id": "alice"})
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "array_has(sender_ids, 'alice')" in where
|
||||
|
||||
|
||||
def test_parent_id_eq_shorthand_renders_scalar_eq() -> None:
|
||||
"""``parent_id`` is a scalar string column → plain ``=``."""
|
||||
node = FilterNode.model_validate({"parent_id": "mc_42"})
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "parent_id = 'mc_42'" in where
|
||||
|
||||
|
||||
def test_top_level_and_renders_grouped_clause() -> None:
|
||||
"""``AND`` combinator compiles like /search — parens-grouped fragments."""
|
||||
node = FilterNode.model_validate(
|
||||
{"AND": [{"session_id": "sess_a"}, {"parent_id": "mc_x"}]}
|
||||
)
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
# Base clause is always first; combinator output appended.
|
||||
assert where.startswith("owner_id = 'u1' AND owner_type = 'user' AND ")
|
||||
assert "session_id = 'sess_a'" in where
|
||||
assert "parent_id = 'mc_x'" in where
|
||||
|
||||
|
||||
def test_top_level_or_renders_grouped_clause() -> None:
|
||||
"""``OR`` combinator emits parens-grouped ``OR`` between sibling preds."""
|
||||
node = FilterNode.model_validate(
|
||||
{"OR": [{"session_id": "sess_a"}, {"session_id": "sess_b"}]}
|
||||
)
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "session_id = 'sess_a'" in where
|
||||
assert "session_id = 'sess_b'" in where
|
||||
assert " OR " in where
|
||||
|
||||
|
||||
def test_ne_operator_renders_not_equal() -> None:
|
||||
"""``ne`` op compiles to ``!=`` on str fields."""
|
||||
node = FilterNode.model_validate({"session_id": {"ne": "sess_internal"}})
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "session_id != 'sess_internal'" in where
|
||||
|
||||
|
||||
def test_timestamp_iso_string_renders_literal() -> None:
|
||||
"""ISO 8601 string is accepted as a timestamp literal (alongside epoch ms)."""
|
||||
node = FilterNode.model_validate(
|
||||
{"timestamp": {"gte": "2026-01-04T00:00:00+00:00"}}
|
||||
)
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "timestamp >= TIMESTAMP '2026-01-04T00:00:00+00:00'" in where
|
||||
|
||||
|
||||
def test_nested_and_inside_or() -> None:
|
||||
"""``AND`` nested inside ``OR`` — combinators compose recursively."""
|
||||
node = FilterNode.model_validate(
|
||||
{
|
||||
"OR": [
|
||||
{"AND": [{"session_id": "sess_a"}, {"parent_id": "mc_x"}]},
|
||||
{"session_id": "sess_b"},
|
||||
]
|
||||
}
|
||||
)
|
||||
where = compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
assert "session_id = 'sess_a'" in where
|
||||
assert "parent_id = 'mc_x'" in where
|
||||
assert "session_id = 'sess_b'" in where
|
||||
assert " OR " in where
|
||||
assert " AND " in where
|
||||
|
||||
|
||||
# ── Malformed value shapes ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_in_op_with_non_list_rejected() -> None:
|
||||
"""``in`` requires a non-empty list — a scalar is a hard error."""
|
||||
node = FilterNode.model_validate({"session_id": {"in": "not_a_list"}})
|
||||
with pytest.raises(FilterError, match="non-empty list"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_in_op_with_empty_list_rejected() -> None:
|
||||
"""``in: []`` is invalid — must contain at least one value."""
|
||||
node = FilterNode.model_validate({"session_id": {"in": []}})
|
||||
with pytest.raises(FilterError, match="non-empty list"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_empty_operator_map_rejected() -> None:
|
||||
"""``{}`` as a field value (no op) is a hard error."""
|
||||
node = FilterNode.model_validate({"timestamp": {}})
|
||||
with pytest.raises(FilterError, match="empty operator map"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_unknown_op_rejected() -> None:
|
||||
"""``between`` / other non-allow-listed ops surface as :class:`FilterError`."""
|
||||
node = FilterNode.model_validate({"timestamp": {"between": [1, 2]}})
|
||||
with pytest.raises(FilterError, match="operator"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_sender_id_gt_rejected() -> None:
|
||||
"""``gt`` on an ``array_str`` column is not supported (semantics unclear)."""
|
||||
node = FilterNode.model_validate({"sender_id": {"gt": "alice"}})
|
||||
with pytest.raises(FilterError, match="not supported on array"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
|
||||
|
||||
def test_non_string_in_str_field_rejected() -> None:
|
||||
"""``session_id`` is a str field — passing an int is a typed error."""
|
||||
node = FilterNode.model_validate({"session_id": {"in": [1, 2]}})
|
||||
with pytest.raises(FilterError, match="must be a string"):
|
||||
compile_filters_for_get(node, owner_id="u1", owner_type="user")
|
||||
350
tests/unit/test_memory/test_get/test_manager.py
Normal file
350
tests/unit/test_memory/test_get/test_manager.py
Normal file
@ -0,0 +1,350 @@
|
||||
"""Unit tests for :class:`GetManager` with in-memory stub repos.
|
||||
|
||||
These tests exercise the dispatch / shape / sort-override logic without
|
||||
LanceDB. Each repo is replaced by a minimal stub that records the call
|
||||
and returns canned rows; the manager's job is to:
|
||||
|
||||
* dispatch on ``memory_type`` to the matching repo,
|
||||
* compile filters once and pass the same ``where`` to the repo,
|
||||
* shape rows into the correct ``GetItem`` (lossless except score),
|
||||
* silently override ``sort_by`` to ``updated_at`` for ``agent_skill``
|
||||
(the table has no ``timestamp`` column),
|
||||
* fetch the owner's single profile row (KV-by-owner) and shape it into
|
||||
``GetProfileItem``, or return ``[]`` on a cold-start miss.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.persistence.lancedb import (
|
||||
AgentCase,
|
||||
AgentSkill,
|
||||
Episode,
|
||||
UserProfile,
|
||||
)
|
||||
from everos.memory.get import (
|
||||
GetManager,
|
||||
GetMemoryType,
|
||||
GetRequest,
|
||||
)
|
||||
from everos.memory.search import FilterNode
|
||||
|
||||
# ── Stub repos ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CallRecord:
|
||||
where: str = ""
|
||||
sort_by: str = ""
|
||||
descending: bool = True
|
||||
page: int = 0
|
||||
page_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubRepo:
|
||||
"""Records the call and returns ``(rows, total)`` verbatim."""
|
||||
|
||||
rows: list[Any] = field(default_factory=list)
|
||||
total: int = 0
|
||||
last: _CallRecord = field(default_factory=_CallRecord)
|
||||
|
||||
async def find_where_paginated(
|
||||
self,
|
||||
where: str,
|
||||
*,
|
||||
sort_by: str,
|
||||
descending: bool = True,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
max_fetch: int = 20000,
|
||||
) -> tuple[list[Any], int]:
|
||||
self.last = _CallRecord(
|
||||
where=where,
|
||||
sort_by=sort_by,
|
||||
descending=descending,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return list(self.rows), self.total
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProfileStubRepo:
|
||||
"""Stub ``user_profile_repo`` — returns its configured row by id."""
|
||||
|
||||
row: Any = None
|
||||
last_id: str | None = None
|
||||
|
||||
async def get_by_id(self, id_: str) -> Any:
|
||||
self.last_id = id_
|
||||
return self.row
|
||||
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ts(day: int = 1) -> _dt.datetime:
|
||||
return _dt.datetime(2026, 1, day, tzinfo=_dt.UTC)
|
||||
|
||||
|
||||
def _episode_row(entry: str) -> Episode:
|
||||
return Episode(
|
||||
id=f"u1_{entry}",
|
||||
entry_id=entry,
|
||||
owner_id="u1",
|
||||
owner_type="user",
|
||||
session_id="sess_a",
|
||||
timestamp=_ts(),
|
||||
parent_type="memcell",
|
||||
parent_id="mc_1",
|
||||
sender_ids=["u1", "assistant"],
|
||||
subject=f"subj {entry}",
|
||||
summary=f"summary {entry}",
|
||||
episode=f"body of {entry}",
|
||||
episode_tokens=f"body of {entry}",
|
||||
md_path=f"users/u1/episodes/{entry}.md",
|
||||
content_sha256="abc",
|
||||
vector=[0.0] * 1024,
|
||||
)
|
||||
|
||||
|
||||
def _agent_case_row(entry: str) -> AgentCase:
|
||||
return AgentCase(
|
||||
id=f"a1_{entry}",
|
||||
entry_id=entry,
|
||||
owner_id="a1",
|
||||
owner_type="agent",
|
||||
session_id="sess_x",
|
||||
timestamp=_ts(),
|
||||
parent_type="memcell",
|
||||
parent_id="mc_99",
|
||||
quality_score=0.8,
|
||||
task_intent=f"intent {entry}",
|
||||
task_intent_tokens=f"intent {entry}",
|
||||
approach=f"approach {entry}",
|
||||
approach_tokens=f"approach {entry}",
|
||||
key_insight=None,
|
||||
md_path=f"agents/a1/cases/{entry}.md",
|
||||
content_sha256="abc",
|
||||
vector=[0.0] * 1024,
|
||||
)
|
||||
|
||||
|
||||
def _agent_skill_row(name: str) -> AgentSkill:
|
||||
return AgentSkill(
|
||||
id=f"a1_{name}",
|
||||
owner_id="a1",
|
||||
owner_type="agent",
|
||||
name=name,
|
||||
description=f"desc {name}",
|
||||
description_tokens=f"desc {name}",
|
||||
content=f"content {name}",
|
||||
content_tokens=f"content {name}",
|
||||
confidence=0.9,
|
||||
maturity_score=0.7,
|
||||
source_case_ids=["a1_ac_1"],
|
||||
md_path=f"agents/a1/skills/{name}/SKILL.md",
|
||||
content_sha256="abc",
|
||||
vector=[0.0] * 1024,
|
||||
)
|
||||
|
||||
|
||||
def _user_profile_row(owner: str = "u1") -> UserProfile:
|
||||
return UserProfile(
|
||||
id=owner,
|
||||
owner_id=owner,
|
||||
owner_type="user",
|
||||
app_id="default",
|
||||
project_id="default",
|
||||
summary=f"{owner} loves climbing in Yosemite",
|
||||
explicit_info_json='[{"category": "Hobby", "description": "climbing"}]',
|
||||
implicit_traits_json='[{"trait": "Outdoorsy"}]',
|
||||
profile_timestamp_ms=1780304400000,
|
||||
md_path=f"users/{owner}/user.md",
|
||||
content_sha256="abc",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_repo() -> _ProfileStubRepo:
|
||||
return _ProfileStubRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(
|
||||
profile_repo: _ProfileStubRepo,
|
||||
) -> tuple[GetManager, _StubRepo, _StubRepo, _StubRepo]:
|
||||
ep = _StubRepo()
|
||||
ac = _StubRepo()
|
||||
sk = _StubRepo()
|
||||
mgr = GetManager(
|
||||
episode_repo=ep, # type: ignore[arg-type]
|
||||
agent_case_repo=ac, # type: ignore[arg-type]
|
||||
agent_skill_repo=sk, # type: ignore[arg-type]
|
||||
user_profile_repo=profile_repo, # type: ignore[arg-type]
|
||||
)
|
||||
return mgr, ep, ac, sk
|
||||
|
||||
|
||||
# ── Episode dispatch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_episodic_memory_populates_episodes_and_counts(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
) -> None:
|
||||
mgr, ep, _, _ = manager
|
||||
ep.rows = [_episode_row("ep_1"), _episode_row("ep_2")]
|
||||
ep.total = 17 # filtered total may exceed the page
|
||||
req = GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
)
|
||||
resp = await mgr.get(req)
|
||||
|
||||
assert len(resp.request_id) == 32 and all(
|
||||
c in "0123456789abcdef" for c in resp.request_id
|
||||
)
|
||||
assert resp.data.total_count == 17
|
||||
assert resp.data.count == 2
|
||||
assert [item.id for item in resp.data.episodes] == ["u1_ep_1", "u1_ep_2"]
|
||||
assert resp.data.profiles == []
|
||||
assert resp.data.agent_cases == []
|
||||
assert resp.data.agent_skills == []
|
||||
# The shaper maps the lance row's owner_id onto the item's user_id field.
|
||||
assert all(item.user_id == "u1" for item in resp.data.episodes)
|
||||
|
||||
|
||||
async def test_episodic_memory_passes_where_and_sort_to_repo(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
) -> None:
|
||||
"""The compiled ``where`` must include owner_id + filter clauses."""
|
||||
mgr, ep, _, _ = manager
|
||||
req = GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.EPISODE,
|
||||
sort_by="timestamp",
|
||||
sort_order="asc",
|
||||
page=2,
|
||||
page_size=10,
|
||||
filters=FilterNode.model_validate({"session_id": "sess_a"}),
|
||||
)
|
||||
await mgr.get(req)
|
||||
assert "owner_id = 'u1'" in ep.last.where
|
||||
assert "owner_type = 'user'" in ep.last.where
|
||||
assert "session_id = 'sess_a'" in ep.last.where
|
||||
assert ep.last.sort_by == "timestamp"
|
||||
assert ep.last.descending is False # asc
|
||||
assert ep.last.page == 2
|
||||
assert ep.last.page_size == 10
|
||||
|
||||
|
||||
# ── Profile dispatch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_profile_miss_returns_empty(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
) -> None:
|
||||
"""Cold start (no profile row yet) → empty list + total_count=0."""
|
||||
mgr, ep, ac, sk = manager # profile_repo.row defaults to None
|
||||
req = GetRequest(
|
||||
user_id="u1",
|
||||
memory_type=GetMemoryType.PROFILE,
|
||||
)
|
||||
resp = await mgr.get(req)
|
||||
assert resp.data.profiles == []
|
||||
assert resp.data.total_count == 0
|
||||
assert resp.data.count == 0
|
||||
# The profile path never touches the paginated (episode/case/skill) repos.
|
||||
assert ep.last.where == ""
|
||||
assert ac.last.where == ""
|
||||
assert sk.last.where == ""
|
||||
|
||||
|
||||
async def test_profile_hit_shapes_row_into_item(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
profile_repo: _ProfileStubRepo,
|
||||
) -> None:
|
||||
"""A present profile row is fetched by owner and shaped + json-decoded."""
|
||||
mgr, *_ = manager
|
||||
profile_repo.row = _user_profile_row("u1")
|
||||
req = GetRequest(user_id="u1", memory_type=GetMemoryType.PROFILE)
|
||||
resp = await mgr.get(req)
|
||||
|
||||
assert resp.data.total_count == 1
|
||||
assert resp.data.count == 1
|
||||
assert len(resp.data.profiles) == 1
|
||||
item = resp.data.profiles[0]
|
||||
assert item.id == "u1"
|
||||
assert item.user_id == "u1"
|
||||
# KV fetch keys on owner_id.
|
||||
assert profile_repo.last_id == "u1"
|
||||
# json buckets are decoded back into structured profile_data.
|
||||
assert item.profile_data["summary"] == "u1 loves climbing in Yosemite"
|
||||
assert item.profile_data["explicit_info"] == [
|
||||
{"category": "Hobby", "description": "climbing"}
|
||||
]
|
||||
assert item.profile_data["implicit_traits"] == [{"trait": "Outdoorsy"}]
|
||||
assert item.profile_data["profile_timestamp_ms"] == 1780304400000
|
||||
|
||||
|
||||
# ── Agent case dispatch ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_agent_case_populates_agent_cases(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
) -> None:
|
||||
mgr, _, ac, _ = manager
|
||||
ac.rows = [_agent_case_row("ac_1"), _agent_case_row("ac_2")]
|
||||
ac.total = 2
|
||||
req = GetRequest(
|
||||
agent_id="a1",
|
||||
memory_type=GetMemoryType.AGENT_CASE,
|
||||
)
|
||||
resp = await mgr.get(req)
|
||||
assert resp.data.total_count == 2
|
||||
assert resp.data.count == 2
|
||||
assert [item.id for item in resp.data.agent_cases] == ["a1_ac_1", "a1_ac_2"]
|
||||
assert resp.data.episodes == []
|
||||
assert resp.data.agent_skills == []
|
||||
|
||||
|
||||
# ── Agent skill dispatch — sort_by silent override ──────────────────────
|
||||
|
||||
|
||||
async def test_agent_skill_sort_by_silently_overridden_to_updated_at(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
) -> None:
|
||||
"""``agent_skill`` always sorts by ``updated_at`` (no ``timestamp`` column)."""
|
||||
mgr, _, _, sk = manager
|
||||
sk.rows = [_agent_skill_row("planner")]
|
||||
sk.total = 1
|
||||
req = GetRequest(
|
||||
agent_id="a1",
|
||||
memory_type=GetMemoryType.AGENT_SKILL,
|
||||
# User passes the default — should be silently downgraded.
|
||||
sort_by="timestamp",
|
||||
)
|
||||
resp = await mgr.get(req)
|
||||
assert sk.last.sort_by == "updated_at"
|
||||
assert resp.data.total_count == 1
|
||||
assert resp.data.agent_skills[0].name == "planner"
|
||||
|
||||
|
||||
async def test_agent_skill_explicit_updated_at_is_respected(
|
||||
manager: tuple[GetManager, _StubRepo, _StubRepo, _StubRepo],
|
||||
) -> None:
|
||||
"""``updated_at`` passes through unchanged (no double-override surprise)."""
|
||||
mgr, _, _, sk = manager
|
||||
req = GetRequest(
|
||||
agent_id="a1",
|
||||
memory_type=GetMemoryType.AGENT_SKILL,
|
||||
sort_by="updated_at",
|
||||
)
|
||||
await mgr.get(req)
|
||||
assert sk.last.sort_by == "updated_at"
|
||||
Reference in New Issue
Block a user