switchroom 0.21.18 → 0.21.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +247 -3
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +6 -1
- package/telegram-plugin/connection-drop.ts +83 -0
- package/telegram-plugin/dist/bridge/bridge.js +41 -2
- package/telegram-plugin/dist/gateway/gateway.js +48 -6
- package/telegram-plugin/dist/server.js +46 -3
- package/telegram-plugin/llm-error-present.ts +25 -0
- package/telegram-plugin/session-tail.ts +38 -1
- package/telegram-plugin/tests/llm-error-present.test.ts +183 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +1 -1
- package/vendor/hindsight-memory/CHANGELOG.md +20 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +26 -3
- package/vendor/hindsight-memory/scripts/prefetch.py +40 -6
- package/vendor/hindsight-memory/scripts/recall.py +219 -18
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +7 -2
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +98 -7
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_topic_guard.py +279 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +74 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""M4 #4778 — prefetch buffer TOPIC-RELEVANCE guard.
|
|
2
|
+
|
|
3
|
+
The F3 freshness machinery proves a joined buffer is FRESH (a strictly-newer
|
|
4
|
+
sentinel this session); it does NOT prove the buffer is ON TOPIC. The producer
|
|
5
|
+
builds the buffer from turn N's last human prompt, so on a topic PIVOT a
|
|
6
|
+
perfectly-fresh buffer holds the WRONG-topic memories. These are OUTCOME tests
|
|
7
|
+
on the rendered ``additionalContext`` (the real injection surface):
|
|
8
|
+
|
|
9
|
+
* RED/GREEN: a pivot query must NOT be served the prior-topic buffer — it must
|
|
10
|
+
fall through to SYNCHRONOUS recall (correct, current-topic memories). On the
|
|
11
|
+
pre-guard code the prior-topic block is injected at warm-buffer latency; with
|
|
12
|
+
the guard the pivot turn shows the synchronous result instead.
|
|
13
|
+
* An on-topic follow-up is STILL a warm hit (the guard is not so tight it kills
|
|
14
|
+
the latency win).
|
|
15
|
+
* A legacy buffer with no stored query fails safe to synchronous recall.
|
|
16
|
+
|
|
17
|
+
Mirrors the harness in ``tests/test_recall_buffer_join.py`` (drives ``recall.main``
|
|
18
|
+
end-to-end, asserts on transport). Stdlib-only.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import unittest
|
|
28
|
+
from unittest import mock
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
32
|
+
if SCRIPTS_DIR not in sys.path:
|
|
33
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
34
|
+
|
|
35
|
+
import recall # noqa: E402
|
|
36
|
+
from lib import recall_buffer # noqa: E402
|
|
37
|
+
|
|
38
|
+
SESSION = "topic-guard-session"
|
|
39
|
+
|
|
40
|
+
# The producer built the warm buffer for turn N's prompt about a DB failover
|
|
41
|
+
# rollback; its recalled block is the us-east-1 standby plan.
|
|
42
|
+
PRODUCER_QUERY = "what's the rollback plan for the us-east-1 primary failover"
|
|
43
|
+
BUFFERED_BLOCK = "- Rollback plan: keep the us-east-1 primary in read-only standby"
|
|
44
|
+
|
|
45
|
+
# Turn N+1 PIVOTS sharply to an unrelated topic (the issue's reproduction shape).
|
|
46
|
+
PIVOT_PROMPT = "what's the weather forecast for Melbourne this weekend"
|
|
47
|
+
SYNC_RECALL_TEXT = "sync recalled: Melbourne weekend outlook is sunny"
|
|
48
|
+
|
|
49
|
+
# An on-topic FOLLOW-UP that reuses the salient nouns of the producer query.
|
|
50
|
+
ONTOPIC_PROMPT = "and is the us-east-1 primary still the rollback target"
|
|
51
|
+
|
|
52
|
+
# --- #4778 review MAJOR: SHORT-PROMPT Jaccard floor -------------------------
|
|
53
|
+
# Jaccard is probabilistic on short prompts. The producer built a buffer for a
|
|
54
|
+
# 2-content-token "call mom" turn; its block is a mom-topic reminder. Turn N+1 is
|
|
55
|
+
# "call ended" — a SHARP pivot that shares only the incidental "call". Raw
|
|
56
|
+
# Jaccard = |{call}| / |{call, mom, ended}| = 1/3 = 0.333 >= 0.30, so the
|
|
57
|
+
# pre-floor guard WRONGLY serves the mom buffer. The small-set intersection floor
|
|
58
|
+
# (either side < 3 tokens => demand >= 2 shared tokens) rejects it.
|
|
59
|
+
SHORT_PRODUCER_QUERY = "call mom"
|
|
60
|
+
SHORT_BUFFERED_BLOCK = "- Reminder: call mom about her dentist appointment"
|
|
61
|
+
SHORT_PIVOT_PROMPT = "call ended"
|
|
62
|
+
SHORT_BUFFER_MARKER = "dentist"
|
|
63
|
+
|
|
64
|
+
# Positive control: two SHORT prompts that LEGITIMATELY share >= 2 content tokens
|
|
65
|
+
# still join the warm buffer. Buffer query has 3 content tokens (restart, klanker,
|
|
66
|
+
# agent — "the" is a stop word); the follow-up "restart klanker" is 2 tokens, so
|
|
67
|
+
# the small-set floor applies, but the intersection {restart, klanker} = 2 clears
|
|
68
|
+
# it and Jaccard = 2/3 = 0.667 clears the ratio. Served, no synchronous recall.
|
|
69
|
+
SHORT_ONTOPIC_PRODUCER_QUERY = "restart the klanker agent"
|
|
70
|
+
SHORT_ONTOPIC_BLOCK = "- Runbook: restart klanker with docker restart switchroom-klanker"
|
|
71
|
+
SHORT_ONTOPIC_PROMPT = "restart klanker"
|
|
72
|
+
SHORT_ONTOPIC_MARKER = "docker"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _SyncClient:
|
|
76
|
+
"""Directive-free client whose synchronous recall returns a marker distinct
|
|
77
|
+
from the buffered block, so a served warm buffer and a served sync result are
|
|
78
|
+
unambiguously distinguishable in the rendered output."""
|
|
79
|
+
|
|
80
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
81
|
+
return {"items": []}
|
|
82
|
+
|
|
83
|
+
def recall(self, bank_id, query, **kwargs):
|
|
84
|
+
return {"results": [{
|
|
85
|
+
"text": SYNC_RECALL_TEXT, "type": "fact",
|
|
86
|
+
"mentioned_at": "2026-01-01", "id": "s1", "scores": {"final": 0.9},
|
|
87
|
+
}]}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TopicGuardBase(unittest.TestCase):
|
|
91
|
+
def setUp(self):
|
|
92
|
+
self._tmpdir = tempfile.mkdtemp(prefix="topic-guard-test-")
|
|
93
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
94
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
95
|
+
|
|
96
|
+
self._bufdir = tempfile.mkdtemp(prefix="topic-guard-buf-")
|
|
97
|
+
self.env = mock.patch.dict(
|
|
98
|
+
os.environ, {"HINDSIGHT_PREFETCH_BUFFER_DIR": self._bufdir}, clear=False
|
|
99
|
+
)
|
|
100
|
+
self.env.start()
|
|
101
|
+
|
|
102
|
+
def tearDown(self):
|
|
103
|
+
self.env.stop()
|
|
104
|
+
shutil.rmtree(self._bufdir, ignore_errors=True)
|
|
105
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
106
|
+
if self._prev is None:
|
|
107
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
108
|
+
else:
|
|
109
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
110
|
+
|
|
111
|
+
def _config(self):
|
|
112
|
+
return {
|
|
113
|
+
"autoRecall": True,
|
|
114
|
+
"bankId": "test-bank",
|
|
115
|
+
"recallMaxTokens": 4096,
|
|
116
|
+
"recallBudget": "mid",
|
|
117
|
+
"recallContextTurns": 1,
|
|
118
|
+
"recallMaxQueryChars": 800,
|
|
119
|
+
"recallPromptPreamble": "",
|
|
120
|
+
"recallParallelDeadlineSeconds": 5,
|
|
121
|
+
"directivesCacheTtlSeconds": 0,
|
|
122
|
+
"memoryPrefetchEnabled": True,
|
|
123
|
+
"memoryPrefetchPollCapMs": 100,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _run(self, config, client, prompt):
|
|
127
|
+
hook_input = {"prompt": prompt, "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
|
|
128
|
+
stdout = io.StringIO()
|
|
129
|
+
with patch("recall.load_config", return_value=config), \
|
|
130
|
+
patch("recall.get_api_url", return_value="http://fake"), \
|
|
131
|
+
patch("recall.HindsightClient", return_value=client), \
|
|
132
|
+
patch("recall.ensure_bank_mission"), \
|
|
133
|
+
patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
|
|
134
|
+
patch("sys.stdout", stdout):
|
|
135
|
+
recall.main()
|
|
136
|
+
out = stdout.getvalue()
|
|
137
|
+
return json.loads(out)["hookSpecificOutput"]["additionalContext"] if out else ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PivotFallsThroughTests(TopicGuardBase):
|
|
141
|
+
def test_pivot_query_is_not_served_the_prior_topic_buffer(self):
|
|
142
|
+
# RED on pre-guard code: `read_if_fresh` returns the fresh buffer and the
|
|
143
|
+
# consumer injects the rollback block for a WEATHER prompt at ~1ms. GREEN
|
|
144
|
+
# with the guard: the topic mismatch falls through to synchronous recall.
|
|
145
|
+
recall_buffer.write_buffer(SESSION, BUFFERED_BLOCK, {}, query=PRODUCER_QUERY)
|
|
146
|
+
recall_buffer.write_sentinel(SESSION)
|
|
147
|
+
|
|
148
|
+
ctx = self._run(self._config(), _SyncClient(), PIVOT_PROMPT)
|
|
149
|
+
|
|
150
|
+
self.assertNotIn(
|
|
151
|
+
"us-east-1", ctx,
|
|
152
|
+
"a topic pivot must NOT be served the prior turn's wrong-topic buffer",
|
|
153
|
+
)
|
|
154
|
+
self.assertIn(
|
|
155
|
+
SYNC_RECALL_TEXT, ctx,
|
|
156
|
+
"a topic pivot must fall through to synchronous recall for correct memories",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class OnTopicStillHitsTests(TopicGuardBase):
|
|
161
|
+
def test_ontopic_followup_still_gets_the_warm_buffer(self):
|
|
162
|
+
# The guard must not be so tight it kills the latency win: a follow-up
|
|
163
|
+
# reusing the salient nouns still clears the Jaccard threshold and joins
|
|
164
|
+
# the warm buffer WITHOUT a synchronous recall.
|
|
165
|
+
class _ExplodingRecallClient:
|
|
166
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
167
|
+
return {"items": []}
|
|
168
|
+
|
|
169
|
+
def recall(self, bank_id, query, **kwargs):
|
|
170
|
+
raise AssertionError("on-topic warm hit must not fall through to synchronous recall")
|
|
171
|
+
|
|
172
|
+
recall_buffer.write_buffer(SESSION, BUFFERED_BLOCK, {}, query=PRODUCER_QUERY)
|
|
173
|
+
recall_buffer.write_sentinel(SESSION)
|
|
174
|
+
|
|
175
|
+
ctx = self._run(self._config(), _ExplodingRecallClient(), ONTOPIC_PROMPT)
|
|
176
|
+
self.assertIn(
|
|
177
|
+
"us-east-1", ctx,
|
|
178
|
+
"an on-topic follow-up must still join the warm prefetch buffer",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class LegacyBufferFailsSafeTests(TopicGuardBase):
|
|
183
|
+
def test_buffer_without_stored_query_falls_through_to_sync_recall(self):
|
|
184
|
+
# Backward-compat: a buffer written before the `query` field existed has
|
|
185
|
+
# query="" -> the guard cannot establish a topic match -> fail-safe to
|
|
186
|
+
# synchronous recall, never a blind wrong-topic serve.
|
|
187
|
+
recall_buffer.write_buffer(SESSION, BUFFERED_BLOCK, {}) # no query
|
|
188
|
+
recall_buffer.write_sentinel(SESSION)
|
|
189
|
+
|
|
190
|
+
ctx = self._run(self._config(), _SyncClient(), PIVOT_PROMPT)
|
|
191
|
+
self.assertNotIn("us-east-1", ctx)
|
|
192
|
+
self.assertIn(SYNC_RECALL_TEXT, ctx)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ShortPromptFloorTests(TopicGuardBase):
|
|
196
|
+
def test_short_pivot_sharing_one_incidental_token_is_not_served(self):
|
|
197
|
+
# RED without the small-set floor: "call mom" buffer + "call ended" turn
|
|
198
|
+
# gives raw Jaccard 1/3 = 0.333 >= 0.30, so the mom-topic block is served
|
|
199
|
+
# on an unrelated turn. GREEN with the floor: either side has < 3 tokens
|
|
200
|
+
# and the intersection is only {call} = 1 < 2, so the guard rejects and
|
|
201
|
+
# the turn falls through to synchronous recall.
|
|
202
|
+
recall_buffer.write_buffer(SESSION, SHORT_BUFFERED_BLOCK, {}, query=SHORT_PRODUCER_QUERY)
|
|
203
|
+
recall_buffer.write_sentinel(SESSION)
|
|
204
|
+
|
|
205
|
+
ctx = self._run(self._config(), _SyncClient(), SHORT_PIVOT_PROMPT)
|
|
206
|
+
|
|
207
|
+
self.assertNotIn(
|
|
208
|
+
SHORT_BUFFER_MARKER, ctx,
|
|
209
|
+
"a short-prompt pivot sharing one incidental token must NOT be served the buffer",
|
|
210
|
+
)
|
|
211
|
+
self.assertIn(
|
|
212
|
+
SYNC_RECALL_TEXT, ctx,
|
|
213
|
+
"a short-prompt pivot must fall through to synchronous recall",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def test_short_ontopic_sharing_two_tokens_still_gets_the_warm_buffer(self):
|
|
217
|
+
# Positive control: the floor must not kill a legitimate short warm hit.
|
|
218
|
+
# "restart klanker" (2 tokens) vs "restart the klanker agent" shares
|
|
219
|
+
# {restart, klanker} = 2, clearing both the small-set floor and the ratio;
|
|
220
|
+
# served WITHOUT any synchronous recall.
|
|
221
|
+
class _ExplodingRecallClient:
|
|
222
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
223
|
+
return {"items": []}
|
|
224
|
+
|
|
225
|
+
def recall(self, bank_id, query, **kwargs):
|
|
226
|
+
raise AssertionError("short on-topic warm hit must not fall through to sync recall")
|
|
227
|
+
|
|
228
|
+
recall_buffer.write_buffer(SESSION, SHORT_ONTOPIC_BLOCK, {}, query=SHORT_ONTOPIC_PRODUCER_QUERY)
|
|
229
|
+
recall_buffer.write_sentinel(SESSION)
|
|
230
|
+
|
|
231
|
+
ctx = self._run(self._config(), _ExplodingRecallClient(), SHORT_ONTOPIC_PROMPT)
|
|
232
|
+
self.assertIn(
|
|
233
|
+
SHORT_ONTOPIC_MARKER, ctx,
|
|
234
|
+
"a short on-topic follow-up sharing >= 2 tokens must still join the warm buffer",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def test_short_floor_unit_boundaries(self):
|
|
238
|
+
cfg = self._config()
|
|
239
|
+
# Short pivot, one incidental shared token -> floor rejects despite ratio.
|
|
240
|
+
self.assertFalse(recall._prefetch_topic_matches(SHORT_PIVOT_PROMPT, SHORT_PRODUCER_QUERY, cfg))
|
|
241
|
+
# Short on-topic, two shared tokens -> floor and ratio both clear.
|
|
242
|
+
self.assertTrue(
|
|
243
|
+
recall._prefetch_topic_matches(SHORT_ONTOPIC_PROMPT, SHORT_ONTOPIC_PRODUCER_QUERY, cfg)
|
|
244
|
+
)
|
|
245
|
+
# The reviewer's borderline case: "kill process 4080" / "process 4080 logs"
|
|
246
|
+
# shares two tokens (both sides 3 tokens) -> served, unaffected by floor.
|
|
247
|
+
self.assertTrue(
|
|
248
|
+
recall._prefetch_topic_matches("kill process 4080", "process 4080 logs", cfg)
|
|
249
|
+
)
|
|
250
|
+
# Full-containment exemption: an identical single-content-token query
|
|
251
|
+
# ("what did we decide" -> {decide} both sides) is wholly shared, not a
|
|
252
|
+
# divergent pivot -> the floor must NOT reject it. Guards the regression
|
|
253
|
+
# the naive floor caused in test_prefetch_invalidation's short queries.
|
|
254
|
+
self.assertTrue(recall._prefetch_topic_matches("what did we decide", "what did we decide", cfg))
|
|
255
|
+
# Subset/narrowing short query is likewise exempt from the floor.
|
|
256
|
+
self.assertTrue(recall._prefetch_topic_matches("call mom please", "call mom", cfg))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TopicMatchUnitTests(TopicGuardBase):
|
|
260
|
+
def test_jaccard_threshold_boundaries(self):
|
|
261
|
+
cfg = self._config()
|
|
262
|
+
# Disjoint content tokens -> pivot -> no match.
|
|
263
|
+
self.assertFalse(recall._prefetch_topic_matches(PIVOT_PROMPT, PRODUCER_QUERY, cfg))
|
|
264
|
+
# Identical query -> match.
|
|
265
|
+
self.assertTrue(recall._prefetch_topic_matches(PRODUCER_QUERY, PRODUCER_QUERY, cfg))
|
|
266
|
+
# On-topic follow-up sharing salient nouns -> match.
|
|
267
|
+
self.assertTrue(recall._prefetch_topic_matches(ONTOPIC_PROMPT, PRODUCER_QUERY, cfg))
|
|
268
|
+
# Empty buffered query (legacy) -> fail-safe miss.
|
|
269
|
+
self.assertFalse(recall._prefetch_topic_matches(PRODUCER_QUERY, "", cfg))
|
|
270
|
+
# Empty current prompt -> fail-safe miss.
|
|
271
|
+
self.assertFalse(recall._prefetch_topic_matches("", PRODUCER_QUERY, cfg))
|
|
272
|
+
# Garbage threshold coerces to the 0.3 default rather than raising.
|
|
273
|
+
bad = dict(cfg)
|
|
274
|
+
bad["memoryPrefetchMinTopicOverlap"] = "not-a-number"
|
|
275
|
+
self.assertTrue(recall._prefetch_topic_matches(PRODUCER_QUERY, PRODUCER_QUERY, bad))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
unittest.main()
|
|
@@ -99,7 +99,10 @@ class BufferJoinBase(unittest.TestCase):
|
|
|
99
99
|
|
|
100
100
|
class FreshHitTests(BufferJoinBase):
|
|
101
101
|
def test_fresh_buffer_hit_is_rendered_with_directives_layered_on(self):
|
|
102
|
-
|
|
102
|
+
# #4778 — the buffered query must be on-topic with the consumer prompt
|
|
103
|
+
# (default "what did we decide about deploys") for the join to fire;
|
|
104
|
+
# here it is identical, so the topic guard passes and the warm hit lands.
|
|
105
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {}, query="what did we decide about deploys")
|
|
103
106
|
recall_buffer.write_sentinel(SESSION)
|
|
104
107
|
|
|
105
108
|
out = self._run(self._config(prefetch_enabled=True), _DirectiveClient())
|
|
@@ -189,5 +192,75 @@ class KillSwitchOffTests(BufferJoinBase):
|
|
|
189
192
|
self.assertNotIn("a prefetched memory", ctx)
|
|
190
193
|
|
|
191
194
|
|
|
195
|
+
class StaleBufferTokenTests(BufferJoinBase):
|
|
196
|
+
"""F3 — a buffer consumed on turn N must NOT be re-served as fresh on
|
|
197
|
+
turns N+1..N+k when no strictly-newer sentinel has been produced."""
|
|
198
|
+
|
|
199
|
+
def test_consumed_buffer_is_not_reserved_as_fresh_next_turn(self):
|
|
200
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {}, query="what did we decide about deploys")
|
|
201
|
+
recall_buffer.write_sentinel(SESSION)
|
|
202
|
+
cfg = self._config(prefetch_enabled=True)
|
|
203
|
+
|
|
204
|
+
# Turn N+1: fresh hit — the buffer is consumed and its token recorded.
|
|
205
|
+
out1 = self._run(cfg, _DirectiveClient())
|
|
206
|
+
ctx1 = json.loads(out1)["hookSpecificOutput"]["additionalContext"]
|
|
207
|
+
self.assertIn("a prefetched memory", ctx1)
|
|
208
|
+
|
|
209
|
+
# Turn N+2: the producer did NOT run again (no new sentinel). The same
|
|
210
|
+
# on-disk buffer must now read as STALE (already-consumed token), so its
|
|
211
|
+
# memory must NOT be injected again. On the pre-fix code — which hard-
|
|
212
|
+
# wired last_consumed_token=None — read_if_fresh reported the old
|
|
213
|
+
# sentinel "fresh" and re-injected the turn-N memory here.
|
|
214
|
+
out2 = self._run(cfg, _DirectiveClient())
|
|
215
|
+
ctx2 = json.loads(out2)["hookSpecificOutput"]["additionalContext"]
|
|
216
|
+
self.assertNotIn(
|
|
217
|
+
"a prefetched memory", ctx2,
|
|
218
|
+
"a buffer consumed on a prior turn must not be re-served as fresh",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def test_a_newer_sentinel_is_served_after_consumption(self):
|
|
222
|
+
# Positive control: once the producer writes a STRICTLY-NEWER sentinel,
|
|
223
|
+
# the fresh path serves again — the token gate rejects only re-reads of
|
|
224
|
+
# an ALREADY-consumed sentinel, never a genuinely new one.
|
|
225
|
+
recall_buffer.write_buffer(SESSION, "- memory one", {}, query="what did we decide about deploys")
|
|
226
|
+
recall_buffer.write_sentinel(SESSION)
|
|
227
|
+
cfg = self._config(prefetch_enabled=True)
|
|
228
|
+
|
|
229
|
+
out1 = self._run(cfg, _DirectiveClient())
|
|
230
|
+
self.assertIn("memory one", json.loads(out1)["hookSpecificOutput"]["additionalContext"])
|
|
231
|
+
|
|
232
|
+
# New turn's producer output.
|
|
233
|
+
recall_buffer.write_buffer(SESSION, "- memory two", {}, query="what did we decide about deploys")
|
|
234
|
+
recall_buffer.write_sentinel(SESSION)
|
|
235
|
+
out2 = self._run(cfg, _DirectiveClient())
|
|
236
|
+
ctx2 = json.loads(out2)["hookSpecificOutput"]["additionalContext"]
|
|
237
|
+
self.assertIn("memory two", ctx2)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ColdSessionSyncRecallTests(BufferJoinBase):
|
|
241
|
+
"""F4 — a flag-on cold session's first turn (no sentinel ever, no prior
|
|
242
|
+
recall, no directives) must fall through to SYNCHRONOUS recall, not emit a
|
|
243
|
+
degraded banner and short-circuit it."""
|
|
244
|
+
|
|
245
|
+
class _MemClient:
|
|
246
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
247
|
+
return {"items": []}
|
|
248
|
+
|
|
249
|
+
def recall(self, bank_id, query, **kwargs):
|
|
250
|
+
return {"results": [{
|
|
251
|
+
"text": "sync recalled memory", "type": "fact",
|
|
252
|
+
"mentioned_at": "2026-01-01", "id": "s1", "scores": {"final": 0.9},
|
|
253
|
+
}]}
|
|
254
|
+
|
|
255
|
+
def test_cold_session_with_no_directives_runs_synchronous_recall(self):
|
|
256
|
+
out = self._run(self._config(prefetch_enabled=True), self._MemClient())
|
|
257
|
+
self.assertTrue(out)
|
|
258
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
259
|
+
self.assertIn(
|
|
260
|
+
"sync recalled memory", ctx,
|
|
261
|
+
"cold session's first turn must run synchronous recall, not a degraded no-op",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
192
265
|
if __name__ == "__main__":
|
|
193
266
|
unittest.main()
|