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.
@@ -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
- recall_buffer.write_buffer(SESSION, "- a prefetched memory", {})
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()