Skip to content

Commit 47668e9

Browse files
maxmilianclaude
andcommitted
test(memory): cover recall-count clamp/fallback; extract resolve_memory_recall_count helper (#4948)
Adopts fresh-review nit: the new robustness logic (malformed-pref fallback + [1,50] clamp) had no test coverage. Extracted the inline pref resolution into a pure resolve_memory_recall_count() helper and added cases for None/NaN/string/ negative/oversized/float inputs. Behaviour unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b3d999a commit 47668e9

3 files changed

Lines changed: 82 additions & 37 deletions

File tree

routes/chat_helpers.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@
3838
)
3939

4040

41+
MEMORY_RECALL_COUNT_MIN = 1
42+
MEMORY_RECALL_COUNT_MAX = 50
43+
44+
45+
def resolve_memory_recall_count(uprefs: dict) -> int:
46+
"""Number of extended memories recalled into context per response (issue #4948).
47+
48+
Resolves user pref -> global setting -> the historical default of 3, coerces to
49+
int, and clamps to [1, 50] so a malformed pref (NaN, negative, string, null)
50+
can't silently disable recall (k<=0) or balloon the prompt.
51+
"""
52+
try:
53+
count = int(uprefs.get("memory_recall_count", get_setting("memory_recall_count", 3)))
54+
except (TypeError, ValueError):
55+
count = 3
56+
return max(MEMORY_RECALL_COUNT_MIN, min(MEMORY_RECALL_COUNT_MAX, count))
57+
58+
4159
def _is_casual_low_signal(text: str) -> bool:
4260
"""Short greetings/slang should not pull memory, skills, RAG, or docs."""
4361
s = str(text or "").strip()
@@ -692,14 +710,7 @@ async def build_chat_context(
692710
# Skills injection respects its own enable toggle (mirrors memory_enabled).
693711
# When off, the "Available skills" index is not added to the prompt.
694712
skills_enabled = not incognito and uprefs.get("skills_enabled", True)
695-
# Number of extended memories recalled per response (issue #4948): user pref,
696-
# then global setting, then the historical default of 3. Clamped so a
697-
# malformed pref can't silently disable recall (k<=0) or balloon the prompt.
698-
try:
699-
mem_recall_count = int(uprefs.get("memory_recall_count", get_setting("memory_recall_count", 3)))
700-
except (TypeError, ValueError):
701-
mem_recall_count = 3
702-
mem_recall_count = max(1, min(50, mem_recall_count))
713+
mem_recall_count = resolve_memory_recall_count(uprefs)
703714
if not allow_tool_preprocessing:
704715
mem_enabled = False
705716
skills_enabled = False

tests/test_memory_recall_count_4948.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,55 @@
22
33
`build_context_preface` retrieves a number of extended (non-pinned) memories to
44
inject into the model context. That count used to be hardcoded; it is now driven
5-
by the `mem_recall_count` parameter so a user setting can control it.
5+
by the `mem_recall_count` parameter (resolved from a user pref) so a setting can
6+
control it.
67
"""
78
from types import SimpleNamespace
89
from unittest.mock import MagicMock
910

11+
import pytest
12+
1013
from src.chat_processor import ChatProcessor
14+
from routes.chat_helpers import (
15+
resolve_memory_recall_count,
16+
MEMORY_RECALL_COUNT_MIN,
17+
MEMORY_RECALL_COUNT_MAX,
18+
)
1119

1220

13-
def _processor_with_memories(monkeypatch, n_extended=12):
14-
"""Build a ChatProcessor whose memory store returns `n_extended` non-pinned memories."""
21+
def _processor_with_memories(n_extended=12):
22+
"""A ChatProcessor whose memory store returns `n_extended` non-pinned memories."""
1523
mem_entries = [
1624
{"id": str(i), "text": f"fact {i}", "category": "fact", "pinned": False}
1725
for i in range(n_extended)
1826
]
1927
memory_manager = MagicMock()
2028
memory_manager.load.return_value = mem_entries
21-
processor = ChatProcessor(memory_manager=memory_manager, personal_docs_manager=MagicMock())
22-
return processor
29+
return ChatProcessor(memory_manager=memory_manager, personal_docs_manager=MagicMock())
2330

2431

25-
def test_mem_recall_count_drives_retrieval_k(monkeypatch):
26-
"""A non-default mem_recall_count is forwarded to the retrieval step as k."""
27-
processor = _processor_with_memories(monkeypatch)
32+
def _capture_k(monkeypatch, processor):
2833
captured = {}
2934

30-
def fake_retrieve(message, mem_entries, k=5):
35+
def fake_retrieve(message, mem_entries, k):
3136
captured["k"] = k
3237
return []
3338

3439
monkeypatch.setattr(processor, "_hybrid_retrieve", fake_retrieve)
40+
return captured
41+
42+
43+
# ── build_context_preface forwards the count to retrieval ───────────────────
44+
45+
def test_mem_recall_count_drives_retrieval_k(monkeypatch):
46+
"""A non-default mem_recall_count is forwarded to the retrieval step as k."""
47+
processor = _processor_with_memories()
48+
captured = _capture_k(monkeypatch, processor)
3549
session = SimpleNamespace(endpoint_url="http://local", model="test", headers={})
3650

3751
processor.build_context_preface(
38-
message="hello",
39-
session=session,
40-
use_web=False,
41-
use_rag=False,
42-
use_memory=True,
43-
use_skills=False,
52+
message="hello", session=session,
53+
use_web=False, use_rag=False, use_memory=True, use_skills=False,
4454
mem_recall_count=7,
4555
)
4656

@@ -49,23 +59,44 @@ def fake_retrieve(message, mem_entries, k=5):
4959

5060
def test_mem_recall_count_defaults_to_current_behavior(monkeypatch):
5161
"""Omitting mem_recall_count preserves the previous hardcoded count (3)."""
52-
processor = _processor_with_memories(monkeypatch)
53-
captured = {}
54-
55-
def fake_retrieve(message, mem_entries, k=5):
56-
captured["k"] = k
57-
return []
58-
59-
monkeypatch.setattr(processor, "_hybrid_retrieve", fake_retrieve)
62+
processor = _processor_with_memories()
63+
captured = _capture_k(monkeypatch, processor)
6064
session = SimpleNamespace(endpoint_url="http://local", model="test", headers={})
6165

6266
processor.build_context_preface(
63-
message="hello",
64-
session=session,
65-
use_web=False,
66-
use_rag=False,
67-
use_memory=True,
68-
use_skills=False,
67+
message="hello", session=session,
68+
use_web=False, use_rag=False, use_memory=True, use_skills=False,
6969
)
7070

7171
assert captured.get("k") == 3
72+
73+
74+
# ── resolve_memory_recall_count: pref resolution + clamp + malformed input ──
75+
76+
def test_resolve_uses_valid_pref():
77+
assert resolve_memory_recall_count({"memory_recall_count": 10}) == 10
78+
79+
80+
def test_resolve_missing_pref_falls_back_to_default():
81+
# No pref -> global setting -> historical default of 3.
82+
assert resolve_memory_recall_count({}) == 3
83+
84+
85+
@pytest.mark.parametrize("bad", [None, "abc", float("nan"), [1], {}])
86+
def test_resolve_malformed_pref_falls_back_to_3(bad):
87+
"""A non-int / NaN / null pref must not throw or disable recall — falls back to 3."""
88+
assert resolve_memory_recall_count({"memory_recall_count": bad}) == 3
89+
90+
91+
def test_resolve_clamps_low_so_recall_is_never_disabled():
92+
# k<=0 would make `queued >= k`-style logic recall nothing; clamp to the floor.
93+
assert resolve_memory_recall_count({"memory_recall_count": 0}) == MEMORY_RECALL_COUNT_MIN
94+
assert resolve_memory_recall_count({"memory_recall_count": -5}) == MEMORY_RECALL_COUNT_MIN
95+
96+
97+
def test_resolve_clamps_high_so_prompt_cannot_balloon():
98+
assert resolve_memory_recall_count({"memory_recall_count": 9999}) == MEMORY_RECALL_COUNT_MAX
99+
100+
101+
def test_resolve_truncates_float():
102+
assert resolve_memory_recall_count({"memory_recall_count": 2.9}) == 2

uv.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)