Files
Drew Cain 0a72d81bb3 fix(mcp): cap recent_activity rows with explicit truncation footer (#785)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:43:42 -05:00

614 lines
22 KiB
Python

"""Tests for discussion context MCP tool."""
from datetime import datetime, timedelta, timezone
from typing import Any, cast
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import recent_activity
from basic_memory.schemas.search import SearchItemType
from basic_memory.schemas.memory import (
ActivityStats,
ProjectActivity,
GraphContext,
MemoryMetadata,
ContextResult,
EntitySummary,
ObservationSummary,
)
# Test data for different timeframe formats
valid_timeframes = [
"7d", # Standard format
"yesterday", # Natural language
"0d", # Zero duration
]
invalid_timeframes = [
"invalid", # Nonsense string
# NOTE: "tomorrow" now returns 1 day ago due to timezone safety - no longer invalid
]
@pytest.mark.asyncio
async def test_recent_activity_timeframe_formats(client, test_project, test_graph):
"""Test that recent_activity accepts various timeframe formats."""
# Test each valid timeframe with project-specific mode
for timeframe in valid_timeframes:
try:
result = await recent_activity(
project=test_project.name,
type=["entity"],
timeframe=timeframe,
)
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert timeframe in result
except Exception as e:
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await recent_activity(project=test_project.name, timeframe=timeframe)
@pytest.mark.asyncio
async def test_recent_activity_type_filters(client, test_project, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single string type
result = await recent_activity(project=test_project.name, type=SearchItemType.ENTITY)
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Recent Notes & Documents" in result
# Test single string type
result = await recent_activity(project=test_project.name, type="entity")
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Recent Notes & Documents" in result
# Test single type
result = await recent_activity(project=test_project.name, type=["entity"])
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Recent Notes & Documents" in result
# Test multiple types
result = await recent_activity(project=test_project.name, type=["entity", "observation"])
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
# Should contain sections for both types
assert "Recent Notes & Documents" in result or "Recent Observations" in result
# Test multiple types
result = await recent_activity(
project=test_project.name, type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION]
)
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
# Should contain sections for both types
assert "Recent Notes & Documents" in result or "Recent Observations" in result
# Test all types
result = await recent_activity(
project=test_project.name, type=["entity", "observation", "relation"]
)
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Activity Summary:" in result
@pytest.mark.asyncio
async def test_recent_activity_type_invalid(client, test_project, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single invalid string type
with pytest.raises(ValueError) as e:
await recent_activity(project=test_project.name, type="note")
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
# Test invalid string array type
with pytest.raises(ValueError) as e:
await recent_activity(project=test_project.name, type=["note"])
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
@pytest.mark.asyncio
async def test_recent_activity_uses_default_project(client, test_project, test_graph):
"""When no project parameter is given, recent_activity uses the default project."""
# Call without explicit project — should resolve to the default
result = await recent_activity()
assert result is not None
assert isinstance(result, str)
# Should return project-specific output for the default project
assert "Recent Activity:" in result
assert "Activity Summary:" in result
def test_recent_activity_format_relative_time_and_truncate_helpers():
"""Unit-test helper formatting to keep MCP output stable."""
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
# _format_relative_time: naive datetime should be treated as UTC.
naive_dt = datetime.now() - timedelta(days=1)
assert recent_activity_module._format_relative_time(naive_dt) in {"yesterday", "recently"}
# ISO string parsing path
iso_dt = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
assert "hour" in recent_activity_module._format_relative_time(iso_dt)
now = datetime.now(timezone.utc)
assert "year" in recent_activity_module._format_relative_time(now - timedelta(days=800))
assert "month" in recent_activity_module._format_relative_time(now - timedelta(days=40))
assert "week" in recent_activity_module._format_relative_time(now - timedelta(days=14))
assert "days ago" in recent_activity_module._format_relative_time(now - timedelta(days=3))
assert "minute" in recent_activity_module._format_relative_time(now - timedelta(minutes=5))
assert recent_activity_module._format_relative_time(now) in {"just now", "recently"}
# Exception fallback
assert recent_activity_module._format_relative_time(object()) == "recently"
# _truncate_at_word: both branches
assert recent_activity_module._truncate_at_word("short", 80) == "short"
assert recent_activity_module._truncate_at_word("word " * 40, 80).endswith("...")
assert recent_activity_module._truncate_at_word("x" * 200, 80).endswith("...")
@pytest.mark.asyncio
async def test_recent_activity_get_project_activity_timezone_normalization(monkeypatch):
"""_get_project_activity should handle naive datetimes and extract active folders."""
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
async def fake_call_get(client, url, params=None):
assert "/memory/recent" in str(url)
t1 = datetime.now() - timedelta(minutes=2)
t2 = datetime.now() - timedelta(minutes=1)
return FakeResponse(
{
"results": [
{
"primary_result": {
"type": "entity",
"external_id": "550e8400-e29b-41d4-a716-446655440001",
"entity_id": 1,
"permalink": "notes/x",
"title": "X",
"content": None,
"file_path": "folder/x.md",
# Naive datetime (no timezone) on purpose.
"created_at": t1.isoformat(),
},
"observations": [],
"related_results": [],
},
{
"primary_result": {
"type": "entity",
"external_id": "550e8400-e29b-41d4-a716-446655440002",
"entity_id": 2,
"permalink": "notes/y",
"title": "Y",
"content": None,
"file_path": "folder/y.md",
"created_at": t2.isoformat(),
},
"observations": [],
"related_results": [],
},
],
"metadata": {"depth": 1, "generated_at": datetime.now(timezone.utc).isoformat()},
}
)
monkeypatch.setattr(recent_activity_module, "call_get", fake_call_get)
class P:
id = 1
external_id = "test-external-id"
name = "p"
path = "/tmp/p"
proj_activity = await recent_activity_module._get_project_activity(
client=None, project_info=cast(Any, P()), params={}, depth=1
)
assert proj_activity.item_count == 2
assert "folder" in proj_activity.active_folders
assert proj_activity.last_activity is not None
def test_recent_activity_format_project_output_no_results():
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
empty = GraphContext(
results=[],
metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)),
)
out = recent_activity_module._format_project_output(
project_name="proj", activity_data=empty, timeframe="7d", type_filter="", page=1
)
assert "No recent activity found" in out
def test_recent_activity_format_project_output_renders_all_entities_and_relations():
"""Regression for #784: the formatter must render every row the API returned.
Previously the body was hardcoded to `[:5]` while the heading reported the
true total — a result set of N>5 entities would show 5 rows under a heading
that claimed N, with no signal the body was truncated. `page_size` is now
the only knob; heading count and body row count must always agree.
"""
import importlib
from basic_memory.schemas.memory import RelationSummary
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
now = datetime.now(timezone.utc)
# Counts chosen to comfortably exceed the old hardcoded `[:5]` slice and any
# plausible reintroduced default cap.
entity_titles = [f"Entity {i}" for i in range(15)]
relation_titles = [f"Relation {i}" for i in range(12)]
results = [
ContextResult(
primary_result=EntitySummary(
external_id=f"550e8400-e29b-41d4-a716-44665544{i:04d}",
entity_id=i,
permalink=f"notes/entity-{i}",
title=title,
content=None,
file_path=f"notes/entity-{i}.md",
created_at=now,
),
observations=[],
related_results=[],
)
for i, title in enumerate(entity_titles)
] + [
ContextResult(
primary_result=RelationSummary(
relation_id=100 + i,
entity_id=i,
title=title,
file_path=f"notes/entity-{i}.md",
permalink=f"notes/entity-{i}",
relation_type="references",
from_entity=f"Entity {i}",
to_entity=f"Entity {i + 1}",
created_at=now,
),
observations=[],
related_results=[],
)
for i, title in enumerate(relation_titles)
]
activity = GraphContext(
results=results,
metadata=MemoryMetadata(depth=1, generated_at=now),
)
out = recent_activity_module._format_project_output(
project_name="proj",
activity_data=activity,
timeframe="7d",
type_filter=["entity", "relation"],
page=1,
)
for title in entity_titles:
assert title in out, f"Entity {title!r} missing from formatter output"
for i in range(len(relation_titles)):
assert f"[[Entity {i}]] → references → [[Entity {i + 1}]]" in out, (
f"Relation {i} missing from formatter output"
)
# Heading total matches the body — no silent truncation.
assert f"Recent Notes & Documents ({len(entity_titles)})" in out
assert f"Recent Connections ({len(relation_titles)})" in out
def test_recent_activity_format_project_output_includes_observation_truncation():
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
long_content = "This is a very long observation " * 10
activity = GraphContext(
results=[
ContextResult(
primary_result=ObservationSummary(
observation_id=1,
entity_id=1,
title="Obs",
file_path="notes/obs.md",
permalink="notes/obs",
category="test",
content=long_content,
created_at=datetime.now(timezone.utc),
),
observations=[],
related_results=[],
)
],
metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)),
)
out = recent_activity_module._format_project_output(
project_name="proj",
activity_data=activity,
timeframe="7d",
type_filter="observation",
page=1,
)
assert "Recent Observations" in out
assert "..." in out # truncated
def test_recent_activity_format_discovery_output_includes_other_active_projects_and_key_developments():
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
now = datetime.now(timezone.utc)
activity_one = GraphContext(
results=[
ContextResult(
primary_result=EntitySummary(
external_id="550e8400-e29b-41d4-a716-446655440001",
entity_id=1,
permalink="docs/complete-feature",
title="Complete Feature Spec",
content=None,
file_path="docs/complete-feature.md",
created_at=now,
),
observations=[],
related_results=[],
)
],
metadata=MemoryMetadata(depth=1, generated_at=now),
)
activity_two = GraphContext(
results=[
ContextResult(
primary_result=EntitySummary(
external_id="550e8400-e29b-41d4-a716-446655440002",
entity_id=2,
permalink="docs/other",
title="Other Note",
content=None,
file_path="docs/other.md",
created_at=now - timedelta(hours=1),
),
observations=[],
related_results=[],
)
],
metadata=MemoryMetadata(depth=1, generated_at=now),
)
projects_activity = {
"A": ProjectActivity(
project_name="A",
project_path="/a",
activity=activity_one,
item_count=2,
last_activity=now,
active_folders=["docs"],
),
"B": ProjectActivity(
project_name="B",
project_path="/b",
activity=activity_two,
item_count=1,
last_activity=now - timedelta(hours=1),
active_folders=[],
),
}
summary = ActivityStats(
total_projects=2,
active_projects=2,
most_active_project="A",
total_items=3,
total_entities=3,
total_relations=0,
total_observations=0,
)
out = recent_activity_module._format_discovery_output(
projects_activity=projects_activity,
summary=summary,
timeframe="7d",
guidance="Session reminder: Remember their project choice throughout this conversation.",
)
assert "Most Active Project:" in out
assert "Other Active Projects:" in out
assert "Key Developments:" in out
@pytest.mark.asyncio
async def test_recent_activity_entity_only_default(client, test_project, test_graph):
"""When no type is specified, recent_activity should default to entity-only.
test_graph creates entities with observations and relations, so if all types
were returned we'd see observation/relation rows in the JSON output.
"""
json_result = await recent_activity(project=test_project.name, output_format="json")
assert isinstance(json_result, list)
assert len(json_result) > 0
# Every item should be an entity — no observations or relations
for item in json_result:
assert item["type"] == "entity", f"Expected entity-only default, got type={item['type']}"
@pytest.mark.asyncio
async def test_recent_activity_explicit_types_returns_requested_types(
client, test_project, test_graph
):
"""Explicitly requesting observation/relation types should return those types."""
# Request observations only
obs_result = await recent_activity(
project=test_project.name, type=["observation"], output_format="json"
)
assert isinstance(obs_result, list)
for item in obs_result:
assert item["type"] == "observation", f"Expected observation type, got type={item['type']}"
# Request relations only
rel_result = await recent_activity(
project=test_project.name, type=["relation"], output_format="json"
)
assert isinstance(rel_result, list)
for item in rel_result:
assert item["type"] == "relation", f"Expected relation type, got type={item['type']}"
# Request all types explicitly
all_result = await recent_activity(
project=test_project.name,
type=["entity", "observation", "relation"],
output_format="json",
)
assert isinstance(all_result, list)
types_found = {item["type"] for item in all_result}
# test_graph creates entities with observations and relations,
# so we expect at least entity and one other type
assert "entity" in types_found
@pytest.mark.asyncio
async def test_recent_activity_pagination_params(client, test_project, test_graph):
"""Test that page and page_size params are forwarded correctly."""
result = await recent_activity(
project=test_project.name,
type=["entity"],
page=1,
page_size=2,
)
assert isinstance(result, str)
assert "Recent Activity:" in result
@pytest.mark.asyncio
async def test_recent_activity_pagination_validation():
"""Invalid page/page_size values should raise clear ValueError messages."""
with pytest.raises(ValueError, match="page must be >= 1, got 0"):
await recent_activity(page=0)
with pytest.raises(ValueError, match="page must be >= 1, got -1"):
await recent_activity(page=-1)
with pytest.raises(ValueError, match="page_size must be >= 1, got 0"):
await recent_activity(page_size=0)
with pytest.raises(ValueError, match="page_size must be >= 1, got -5"):
await recent_activity(page_size=-5)
with pytest.raises(ValueError, match="page_size must be <= 100, got 999"):
await recent_activity(page_size=999)
def test_format_project_output_has_more_pagination_guidance():
"""When has_more is True, activity summary should show pagination guidance."""
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
now = datetime.now(timezone.utc)
activity = GraphContext(
results=[
ContextResult(
primary_result=EntitySummary(
external_id="550e8400-e29b-41d4-a716-446655440001",
entity_id=1,
permalink="notes/test",
title="Test Note",
content=None,
file_path="notes/test.md",
created_at=now,
),
observations=[],
related_results=[],
)
],
metadata=MemoryMetadata(depth=1, generated_at=now),
has_more=True,
)
out = recent_activity_module._format_project_output(
project_name="proj",
activity_data=activity,
timeframe="7d",
type_filter="entity",
page=1,
)
assert "Use page=2 to see more" in out
assert "Showing 1 items (page 1)" in out
def test_format_project_output_no_more_pages():
"""When has_more is False, activity summary should not show pagination guidance."""
import importlib
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
now = datetime.now(timezone.utc)
activity = GraphContext(
results=[
ContextResult(
primary_result=EntitySummary(
external_id="550e8400-e29b-41d4-a716-446655440001",
entity_id=1,
permalink="notes/test",
title="Test Note",
content=None,
file_path="notes/test.md",
created_at=now,
),
observations=[],
related_results=[],
)
],
metadata=MemoryMetadata(depth=1, generated_at=now),
has_more=False,
)
out = recent_activity_module._format_project_output(
project_name="proj",
activity_data=activity,
timeframe="7d",
type_filter="entity",
page=1,
)
assert "1 items found." in out
assert "Use page=" not in out