switchroom 0.19.24 → 0.19.26

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.
Files changed (59) hide show
  1. package/dist/agent-scheduler/index.js +20 -7
  2. package/dist/auth-broker/index.js +93 -28
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +20 -6
  7. package/dist/cli/switchroom.js +3091 -1435
  8. package/dist/host-control/main.js +92 -29
  9. package/dist/vault/approvals/kernel-server.js +92 -28
  10. package/dist/vault/broker/server.js +258 -71
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/cron-session.sh.hbs +6 -0
  14. package/profiles/_base/start.sh.hbs +92 -17
  15. package/skills/switchroom-health/SKILL.md +19 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  18. package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
  19. package/telegram-plugin/gateway/gateway.ts +53 -52
  20. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  21. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  22. package/telegram-plugin/quota-bar-format.ts +4 -1
  23. package/telegram-plugin/status-no-truncate.ts +49 -0
  24. package/telegram-plugin/status-pin-driver.ts +28 -0
  25. package/telegram-plugin/status-pin.ts +33 -4
  26. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  27. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  28. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  29. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  30. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  31. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  32. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  33. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  34. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  35. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  36. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  37. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  38. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  39. package/telegram-plugin/tier-downgrade.ts +3 -2
  40. package/telegram-plugin/tool-activity-summary.ts +61 -18
  41. package/telegram-plugin/uat/assertions.ts +21 -2
  42. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  43. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  44. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  45. package/telegram-plugin/worker-activity-feed.ts +38 -17
  46. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  47. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  48. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  49. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  59. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -0,0 +1,509 @@
1
+ """Switchroom — per-bank slot reservation inside the recall count cap.
2
+
3
+ The bug these tests guard is compositional, not volumetric. Recall fans out to
4
+ the agent's OWN bank and the sender's profile bank, merges the results, sorts
5
+ them globally by ``scores.final`` and head-slices at ``recallMaxMemories``. That
6
+ head-slice is winner-take-all across banks: on a turn where both banks return
7
+ more candidates than the cap, one bank's score distribution can fill every slot,
8
+ and the agent is handed a dossier about its operator with none of its own
9
+ session memory.
10
+
11
+ Scope, stated the way the code states it: this is score-based crowd-out among
12
+ results that DID return. A timed-out bank contributes zero candidates, so
13
+ reservation is a strict no-op there — the own-bank timeout is a separate defect,
14
+ and the composition telemetry asserted below (``injected_own_bank_count``) is
15
+ what makes THAT one legible per-turn, since a fully timed-out own bank still
16
+ logs a full ``result_count``.
17
+
18
+ Two properties are load-bearing and each has a test that fails without it:
19
+
20
+ * the floors bind — neither bank can be zeroed out when it returned results;
21
+ * the floors are FLOORS — they may claim at most half the cap between them,
22
+ so ``scores.final`` still decides the rest and composition still moves when
23
+ the score regime flips. Floors summing to the cap (4+2 at the fleet's
24
+ deployed cap of 6) would be a fixed quota with no score influence at all.
25
+
26
+ So every test here asserts COMPOSITION (which bank the injected memories came
27
+ from), never just a count. A test that passes when all six injected memories
28
+ come from the profile bank is not a test for this bug.
29
+
30
+ Stdlib-only (unittest + mock); runs under ``python3 -m unittest discover
31
+ tests/``. Harness mirrors ``test_recall_integration.py``.
32
+ """
33
+
34
+ import io
35
+ import json
36
+ import os
37
+ import shutil
38
+ import sys
39
+ import tempfile
40
+ import unittest
41
+ from unittest.mock import patch
42
+
43
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
44
+ if SCRIPTS_DIR not in sys.path:
45
+ sys.path.insert(0, SCRIPTS_DIR)
46
+
47
+ import recall # noqa: E402
48
+
49
+ OWN = "test-bank"
50
+ PROFILE = "ken-profile"
51
+
52
+ # Switchroom's shipped floors, sized against the cap the fleet actually deploys
53
+ # (`defaults.memory.recall.max_memories: 6`, which cascades to
54
+ # HINDSIGHT_RECALL_MAX_MEMORIES and wins over the 8 in settings.json). Kept as
55
+ # named constants so a change to the shipped pair has to come here too.
56
+ SHIPPED_CAP = 6
57
+ SHIPPED_OWN_FLOOR = 2
58
+ SHIPPED_ADDITIONAL_FLOOR = 1
59
+
60
+
61
+ def _memory(text, score, mem_id=None):
62
+ """A recall result carrying the engine's combined relevance score."""
63
+ return {
64
+ "text": text,
65
+ "type": "fact",
66
+ "mentioned_at": "2026-01-01",
67
+ "id": mem_id or text,
68
+ "scores": {"final": score},
69
+ }
70
+
71
+
72
+ class _PerBankClient:
73
+ """Fake HindsightClient returning a different result set per bank."""
74
+
75
+ def __init__(self, per_bank, directives=None, bank_exc=None):
76
+ self._per_bank = per_bank
77
+ self._directives = directives or []
78
+ self._bank_exc = bank_exc or {}
79
+
80
+ def list_directives(self, bank_id, active_only=True, timeout=2):
81
+ return {"items": list(self._directives)}
82
+
83
+ def recall(self, bank_id, query, **kwargs):
84
+ exc = self._bank_exc.get(bank_id)
85
+ if exc is not None:
86
+ raise exc
87
+ # Deep-ish copy: main() stamps a private source-bank key onto results,
88
+ # and a shared list would leak that between calls.
89
+ return {"results": [dict(m) for m in self._per_bank.get(bank_id, [])]}
90
+
91
+
92
+ def _run_main_with(client, prompt="what did we decide", config_extra=None):
93
+ hook_input = {
94
+ "prompt": prompt,
95
+ "session_id": "test-session",
96
+ "transcript_path": "",
97
+ "cwd": "/tmp",
98
+ }
99
+ config = {
100
+ "autoRecall": True,
101
+ "bankId": OWN,
102
+ "recallMaxTokens": 1024,
103
+ "recallBudget": "mid",
104
+ "recallContextTurns": 1,
105
+ "recallMaxQueryChars": 800,
106
+ "recallPromptPreamble": "",
107
+ "directivesCacheTtlSeconds": 0,
108
+ "recallAdditionalBanks": [PROFILE],
109
+ }
110
+ if config_extra:
111
+ config.update(config_extra)
112
+
113
+ stdout = io.StringIO()
114
+ stderr = io.StringIO()
115
+ with patch.object(recall, "load_config", return_value=config), patch.object(
116
+ recall, "get_api_url", return_value="http://localhost:18888"
117
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
118
+ recall, "ensure_bank_mission", return_value=None
119
+ ), patch.object(recall, "write_state", return_value=None), patch(
120
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
121
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
122
+ recall.main()
123
+
124
+ raw = stdout.getvalue()
125
+ if not raw.strip():
126
+ return None, raw
127
+ return json.loads(raw)["hookSpecificOutput"]["additionalContext"], raw
128
+
129
+
130
+ class ReserveBankSlotsUnitTests(unittest.TestCase):
131
+ """`_reserve_bank_slots` in isolation."""
132
+
133
+ @staticmethod
134
+ def _mk(bank, n, base):
135
+ return [
136
+ dict(_memory(f"{bank}-{i}", base - i * 0.001), **{recall.SOURCE_BANK_KEY: bank})
137
+ for i in range(n)
138
+ ]
139
+
140
+ def _select(
141
+ self,
142
+ own_n,
143
+ add_n,
144
+ cap=SHIPPED_CAP,
145
+ own_floor=SHIPPED_OWN_FLOOR,
146
+ add_floor=SHIPPED_ADDITIONAL_FLOOR,
147
+ favour=PROFILE,
148
+ ):
149
+ """Run the real `_reserve_bank_slots` under a chosen score regime.
150
+
151
+ `favour` picks which bank's candidates outrank the other's end to end.
152
+ Both regimes matter: the crowd-out this fixes is symmetric in the code
153
+ even though the profile-favoured direction is the one seen in
154
+ production, and a suite that only ever ranks profile above own cannot
155
+ tell a working additional-bank floor from a missing one.
156
+ """
157
+ hi, lo = (0.9, 0.5) if favour == PROFILE else (0.5, 0.9)
158
+ merged = self._mk(PROFILE, add_n, hi) + self._mk(OWN, own_n, lo)
159
+ recall._sort_by_final_score(merged)
160
+ sel, r_own, r_add = recall._reserve_bank_slots(
161
+ merged, cap, OWN, own_floor, add_floor
162
+ )
163
+ banks = [m[recall.SOURCE_BANK_KEY] for m in sel]
164
+ return sel, banks.count(OWN), banks.count(PROFILE), r_own, r_add
165
+
166
+ def test_profile_bank_cannot_take_every_slot(self):
167
+ """THE BUG. Profile outranks own on every candidate; without floors the
168
+ head-slice hands it all six slots."""
169
+ merged = self._mk(PROFILE, 10, 0.9) + self._mk(OWN, 10, 0.5)
170
+ recall._sort_by_final_score(merged)
171
+ baseline, _, _ = recall._reserve_bank_slots(merged, SHIPPED_CAP, OWN, 0, 0)
172
+ self.assertEqual(
173
+ [m[recall.SOURCE_BANK_KEY] for m in baseline].count(OWN),
174
+ 0,
175
+ "precondition: with floors off the profile bank takes all six slots",
176
+ )
177
+
178
+ _, own_n, add_n, r_own, r_add = self._select(10, 10)
179
+ self.assertEqual(own_n, 2, "own bank must keep its reserved floor")
180
+ self.assertEqual(add_n, 4, "the non-reserved slots still go to relevance")
181
+ self.assertEqual(r_own, 2, "both own slots were won by reservation")
182
+ self.assertEqual(r_add, 0)
183
+
184
+ def test_own_bank_cannot_take_every_slot_either(self):
185
+ """B2 anchor — the ADDITIONAL floor, in the score regime where it is the
186
+ only thing standing between the profile bank and zero slots.
187
+
188
+ Own outranks profile on every candidate here, so the head-slice and the
189
+ own floor both point the same way; delete `additional_take` and the
190
+ profile bank gets nothing. This is the mirror of the test above and the
191
+ one the original suite was missing.
192
+ """
193
+ merged = self._mk(PROFILE, 10, 0.5) + self._mk(OWN, 10, 0.9)
194
+ recall._sort_by_final_score(merged)
195
+ baseline, _, _ = recall._reserve_bank_slots(merged, SHIPPED_CAP, OWN, 0, 0)
196
+ self.assertEqual(
197
+ [m[recall.SOURCE_BANK_KEY] for m in baseline].count(PROFILE),
198
+ 0,
199
+ "precondition: with floors off the own bank takes all six slots",
200
+ )
201
+
202
+ _, own_n, add_n, r_own, r_add = self._select(10, 10, favour=OWN)
203
+ self.assertEqual(
204
+ add_n,
205
+ SHIPPED_ADDITIONAL_FLOOR,
206
+ "additional-bank floor did not bind — the profile bank was zeroed",
207
+ )
208
+ self.assertEqual(own_n, 5)
209
+ self.assertEqual(
210
+ r_add,
211
+ SHIPPED_ADDITIONAL_FLOOR,
212
+ "the profile slot was won by reservation, not by score",
213
+ )
214
+ self.assertEqual(r_own, 0)
215
+
216
+ def test_scores_still_decide_composition_at_the_deployed_cap(self):
217
+ """B1 anchor — floors that consume the whole cap are not floors.
218
+
219
+ At the fleet's deployed cap of 6, floors of 4/2 would sum to the cap:
220
+ `remaining` would be 0, the global-relevance fill loop would never run,
221
+ and every turn where both banks return would produce the identical 4/2
222
+ split no matter what `scores.final` said. `_reservable_slots` bounds the
223
+ floors to half the cap, so composition still moves with the scores —
224
+ asserted here for BOTH the shipped 2/1 and an operator's oversized 4/2.
225
+ """
226
+ for own_floor, add_floor in ((SHIPPED_OWN_FLOOR, SHIPPED_ADDITIONAL_FLOOR), (4, 2)):
227
+ with self.subTest(floors=(own_floor, add_floor)):
228
+ _, own_hi, _, _, _ = self._select(
229
+ 10, 10, own_floor=own_floor, add_floor=add_floor, favour=OWN
230
+ )
231
+ _, own_lo, _, _, _ = self._select(
232
+ 10, 10, own_floor=own_floor, add_floor=add_floor, favour=PROFILE
233
+ )
234
+ self.assertGreater(
235
+ own_hi,
236
+ own_lo,
237
+ "composition is identical across score regimes — the floors "
238
+ "have become a fixed quota and scores.final is inert",
239
+ )
240
+
241
+ def test_floors_never_claim_more_than_half_the_cap(self):
242
+ """The headroom invariant itself, across caps. At least ceil(cap/2)
243
+ slots are always awarded on pure global relevance, so an oversized floor
244
+ pair can never turn into a quota at any cap an operator picks."""
245
+ for cap in (2, 4, 6, 8, 12):
246
+ with self.subTest(cap=cap):
247
+ # Floors far larger than the cap, i.e. the worst case.
248
+ _, own_n, add_n, r_own, r_add = self._select(
249
+ 20, 20, cap=cap, own_floor=99, add_floor=99
250
+ )
251
+ self.assertEqual(own_n + add_n, cap)
252
+ self.assertLessEqual(
253
+ r_own + r_add,
254
+ cap // 2,
255
+ "reservation claimed more than half the cap",
256
+ )
257
+
258
+ def test_floor_is_not_a_quota_when_own_bank_is_silent(self):
259
+ """A timed-out own bank must not waste its reserved slots."""
260
+ _, own_n, add_n, _, _ = self._select(0, 10)
261
+ self.assertEqual(own_n, 0)
262
+ self.assertEqual(add_n, 6, "unclaimed own slots go to the profile bank")
263
+
264
+ def test_floor_is_not_a_quota_when_profile_bank_is_silent(self):
265
+ _, own_n, add_n, _, _ = self._select(10, 0)
266
+ self.assertEqual(own_n, 6)
267
+ self.assertEqual(add_n, 0)
268
+
269
+ def test_partial_own_results_take_only_what_they_have(self):
270
+ _, own_n, add_n, _, _ = self._select(1, 10)
271
+ self.assertEqual(own_n, 1)
272
+ self.assertEqual(add_n, 5)
273
+
274
+ def test_zero_floors_are_the_pre_fix_head_slice(self):
275
+ _, own_n, add_n, r_own, r_add = self._select(10, 10, own_floor=0, add_floor=0)
276
+ self.assertEqual(own_n, 0)
277
+ self.assertEqual(add_n, 6)
278
+ self.assertEqual((r_own, r_add), (0, 0))
279
+
280
+ def test_own_floor_wins_when_floors_exceed_the_reservation_budget(self):
281
+ """Floors of 4/2 against a cap of 4 can't both be honoured. The own
282
+ floor takes the whole reservation budget (half the cap = 2) and the
283
+ additional floor gets nothing — but the other two slots are still
284
+ awarded on relevance, so the profile bank is not shut out."""
285
+ _, own_n, add_n, _, _ = self._select(10, 10, cap=4, own_floor=4, add_floor=2)
286
+ self.assertEqual(own_n, 2, "own floor clamped to the reservation budget")
287
+ self.assertEqual(add_n, 2, "the remaining half went to global relevance")
288
+
289
+ def test_selection_is_returned_in_relevance_order(self):
290
+ """Reservation changes WHICH memories are injected, never the order."""
291
+ sel, _, _, _, _ = self._select(10, 10)
292
+ scores = [recall._result_final_score(m) for m in sel]
293
+ self.assertEqual(scores, sorted(scores, reverse=True))
294
+
295
+ def test_no_cap_is_passthrough(self):
296
+ merged = self._mk(PROFILE, 3, 0.9) + self._mk(OWN, 3, 0.5)
297
+ sel, r_own, r_add = recall._reserve_bank_slots(merged, 0, OWN, 4, 2)
298
+ self.assertEqual(len(sel), 6)
299
+ self.assertEqual((r_own, r_add), (0, 0))
300
+
301
+ def test_result_set_smaller_than_cap_is_passthrough(self):
302
+ merged = self._mk(PROFILE, 2, 0.9) + self._mk(OWN, 2, 0.5)
303
+ sel, r_own, r_add = recall._reserve_bank_slots(merged, 6, OWN, 4, 2)
304
+ self.assertEqual(len(sel), 4)
305
+ self.assertEqual((r_own, r_add), (0, 0))
306
+
307
+ def test_no_duplicates_and_exact_cap(self):
308
+ _, own_n, add_n, _, _ = self._select(10, 10)
309
+ self.assertEqual(own_n + add_n, 6)
310
+ sel, _, _, _, _ = self._select(10, 10)
311
+ self.assertEqual(len({id(m) for m in sel}), 6)
312
+
313
+ def test_untagged_results_count_as_additional(self):
314
+ """A result with no stamped source bank must never be credited to the
315
+ own bank — the own-bank number is never optimistic."""
316
+ merged = [_memory(f"untagged-{i}", 0.9 - i * 0.01) for i in range(10)]
317
+ sel, r_own, _ = recall._reserve_bank_slots(merged, 6, OWN, 4, 2)
318
+ self.assertEqual(len(sel), 6)
319
+ self.assertEqual(r_own, 0)
320
+ self.assertEqual(
321
+ recall._injected_bank_composition(sel, OWN)["injected_own_bank_count"], 0
322
+ )
323
+
324
+
325
+ class _LogTestBase(unittest.TestCase):
326
+ def setUp(self):
327
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-slots-test-")
328
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
329
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
330
+
331
+ def tearDown(self):
332
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
333
+ if self._prev is None:
334
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
335
+ else:
336
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
337
+
338
+ def _read_log(self):
339
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
340
+ if not os.path.isfile(path):
341
+ return []
342
+ with open(path, encoding="utf-8") as f:
343
+ return [json.loads(line) for line in f if line.strip()]
344
+
345
+
346
+ class SlotReservationIntegrationTests(_LogTestBase):
347
+ """Wired through main(): the injected block itself must carry own-bank
348
+ memories, not just the right count of memories."""
349
+
350
+ # Every profile memory outranks every own memory — the production shape.
351
+ PROFILE_MEMS = [_memory(f"profile-fact-{i}", 0.99 - i * 0.001) for i in range(10)]
352
+ OWN_MEMS = [_memory(f"own-fact-{i}", 0.5 - i * 0.001) for i in range(10)]
353
+ # The inverted regime. Not hypothetical — `scores.final` is a per-query
354
+ # rerank, so which bank leads flips with the prompt. It is also the only
355
+ # regime in which the ADDITIONAL floor is load-bearing.
356
+ OWN_MEMS_HIGH = [_memory(f"own-fact-{i}", 0.99 - i * 0.001) for i in range(10)]
357
+ PROFILE_MEMS_LOW = [_memory(f"profile-fact-{i}", 0.5 - i * 0.001) for i in range(10)]
358
+
359
+ def _client(self, **kw):
360
+ return _PerBankClient({OWN: self.OWN_MEMS, PROFILE: self.PROFILE_MEMS}, **kw)
361
+
362
+ def _client_own_favoured(self, **kw):
363
+ return _PerBankClient(
364
+ {OWN: self.OWN_MEMS_HIGH, PROFILE: self.PROFILE_MEMS_LOW}, **kw
365
+ )
366
+
367
+ def test_without_floors_profile_bank_starves_own_bank(self):
368
+ """Precondition/regression anchor: this is today's behaviour."""
369
+ ctx, _ = _run_main_with(
370
+ self._client(), config_extra={"recallMaxMemories": SHIPPED_CAP}
371
+ )
372
+ self.assertIsNotNone(ctx)
373
+ self.assertNotIn("own-fact-", ctx)
374
+ e = self._read_log()[0]
375
+ self.assertEqual(e["result_count"], 6)
376
+ self.assertEqual(e["injected_own_bank_count"], 0)
377
+ self.assertEqual(e["injected_additional_bank_count"], 6)
378
+
379
+ def test_without_floors_own_bank_starves_profile_bank(self):
380
+ """The mirror precondition, in the inverted score regime — without the
381
+ additional floor the profile bank reaches the prompt zero times."""
382
+ ctx, _ = _run_main_with(
383
+ self._client_own_favoured(), config_extra={"recallMaxMemories": SHIPPED_CAP}
384
+ )
385
+ self.assertIsNotNone(ctx)
386
+ self.assertNotIn("profile-fact-", ctx)
387
+ e = self._read_log()[0]
388
+ self.assertEqual(e["result_count"], 6)
389
+ self.assertEqual(e["injected_own_bank_count"], 6)
390
+ self.assertEqual(e["injected_additional_bank_count"], 0)
391
+
392
+ def test_floors_guarantee_profile_memories_reach_the_prompt(self):
393
+ """B2 — end-to-end proof that `recallAdditionalBankMinSlots` does
394
+ something. Deleting the knob makes this fail: own outranks profile on
395
+ every candidate, so only the floor puts a profile fact in the prompt."""
396
+ ctx, _ = _run_main_with(
397
+ self._client_own_favoured(),
398
+ config_extra={
399
+ "recallMaxMemories": SHIPPED_CAP,
400
+ "recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
401
+ "recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
402
+ },
403
+ )
404
+ self.assertIsNotNone(ctx)
405
+ profile_lines = [ln for ln in ctx.splitlines() if "profile-fact-" in ln]
406
+ self.assertEqual(
407
+ len(profile_lines),
408
+ SHIPPED_ADDITIONAL_FLOOR,
409
+ "additional-bank floor did not reach the injected block",
410
+ )
411
+ e = self._read_log()[0]
412
+ self.assertEqual(e["injected_additional_bank_count"], SHIPPED_ADDITIONAL_FLOOR)
413
+ self.assertEqual(e["injected_own_bank_count"], 5)
414
+ self.assertEqual(
415
+ e["reserved_additional_slots"],
416
+ SHIPPED_ADDITIONAL_FLOOR,
417
+ "the profile slot must be recorded as won by reservation, not score",
418
+ )
419
+ self.assertEqual(e["reserved_own_slots"], 0)
420
+
421
+ def test_floors_guarantee_own_bank_memories_reach_the_prompt(self):
422
+ ctx, _ = _run_main_with(
423
+ self._client(),
424
+ config_extra={
425
+ "recallMaxMemories": SHIPPED_CAP,
426
+ "recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
427
+ "recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
428
+ },
429
+ )
430
+ self.assertIsNotNone(ctx)
431
+ own_lines = [ln for ln in ctx.splitlines() if "own-fact-" in ln]
432
+ profile_lines = [ln for ln in ctx.splitlines() if "profile-fact-" in ln]
433
+ self.assertEqual(
434
+ len(own_lines), SHIPPED_OWN_FLOOR, "own bank did not get its floor"
435
+ )
436
+ # The other four are still won on relevance, not handed out by quota.
437
+ self.assertEqual(len(profile_lines), 4)
438
+
439
+ def test_log_row_records_injected_bank_composition(self):
440
+ """The field that would have caught the own-bank timeout outage."""
441
+ _run_main_with(
442
+ self._client(),
443
+ config_extra={
444
+ "recallMaxMemories": SHIPPED_CAP,
445
+ "recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
446
+ "recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
447
+ },
448
+ )
449
+ e = self._read_log()[0]
450
+ self.assertEqual(e["result_count"], 6)
451
+ self.assertEqual(e["injected_own_bank_count"], SHIPPED_OWN_FLOOR)
452
+ self.assertEqual(e["injected_additional_bank_count"], 4)
453
+ self.assertEqual(e["reserved_own_slots"], SHIPPED_OWN_FLOOR)
454
+ self.assertEqual(e["reserved_additional_slots"], 0)
455
+
456
+ def test_own_bank_timeout_is_visible_in_the_log_row(self):
457
+ """A fully timed-out own bank must NOT look healthy: result_count is
458
+ still 6, but the composition fields expose the collapse."""
459
+ import socket
460
+
461
+ _run_main_with(
462
+ self._client(bank_exc={OWN: socket.timeout("timed out")}),
463
+ config_extra={
464
+ "recallMaxMemories": SHIPPED_CAP,
465
+ "recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
466
+ "recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
467
+ },
468
+ )
469
+ e = self._read_log()[0]
470
+ self.assertEqual(e["result_count"], 6, "volume telemetry still reads healthy")
471
+ self.assertEqual(e["injected_own_bank_count"], 0, "composition exposes it")
472
+ self.assertEqual(e["injected_additional_bank_count"], 6)
473
+ self.assertTrue(e["deadline_hit"])
474
+
475
+ def test_source_bank_key_never_leaks_into_the_injected_block(self):
476
+ ctx, _ = _run_main_with(
477
+ self._client(),
478
+ config_extra={
479
+ "recallMaxMemories": SHIPPED_CAP,
480
+ "recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
481
+ "recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
482
+ },
483
+ )
484
+ self.assertNotIn(recall.SOURCE_BANK_KEY, ctx)
485
+ self.assertNotIn("ken-profile", ctx)
486
+
487
+ def test_serial_mode_tags_source_banks_too(self):
488
+ """The serial rollback path (HINDSIGHT_RECALL_PARALLEL=false) must not
489
+ silently lose reservation."""
490
+ ctx, _ = _run_main_with(
491
+ self._client(),
492
+ config_extra={
493
+ "recallMaxMemories": SHIPPED_CAP,
494
+ "recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
495
+ "recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
496
+ "recallParallel": False,
497
+ },
498
+ )
499
+ self.assertIsNotNone(ctx)
500
+ self.assertEqual(
501
+ len([ln for ln in ctx.splitlines() if "own-fact-" in ln]), SHIPPED_OWN_FLOOR
502
+ )
503
+ e = self._read_log()[0]
504
+ self.assertEqual(e["recall_mode"], "serial")
505
+ self.assertEqual(e["injected_own_bank_count"], SHIPPED_OWN_FLOOR)
506
+
507
+
508
+ if __name__ == "__main__":
509
+ unittest.main()
@@ -9,8 +9,10 @@ Two single-concern guarantees:
9
9
  the one recorded in ``recall_log.jsonl`` (acceptance: no ``<channel``
10
10
  substring in the logged query). The multi-turn fixture is a regression
11
11
  guard for the future ``recallContextTurns`` default flip (A2): it locks
12
- in that the composed query — both its "Prior context:" lines and its
13
- trailing latest-query segment — is envelope-free.
12
+ in that the composed query — both its prior-context turns and its
13
+ trailing latest-query segment — is envelope-free. Since #3757 the wire
14
+ query is additionally term-shaped, so these assert the hygiene invariant
15
+ (which terms may appear) rather than a byte-exact string.
14
16
 
15
17
  2. Telemetry — every non-cache recall log carries per-bank latency +
16
18
  timeout flags, directives-fetch latency, total critical-path wall time,
@@ -130,7 +132,14 @@ class SingleTurnEnvelopeStrip(unittest.TestCase):
130
132
  self.assertEqual(len(wrapped_client.queries), 1)
131
133
  self.assertEqual(len(bare_client.queries), 1)
132
134
  self.assertEqual(wrapped_client.queries[0], bare_client.queries[0])
133
- self.assertEqual(wrapped_client.queries[0], BARE)
135
+ # #3757 shapes the wire query (drops stopwords, caps BM25 terms), so
136
+ # this is no longer byte-identical to BARE. The guarantee under test
137
+ # is envelope hygiene: every surviving term came from the inner text,
138
+ # and the question's content words are all still there.
139
+ terms = wrapped_client.queries[0].lower().split()
140
+ self.assertTrue(set(terms) <= set(BARE.split()), terms)
141
+ for word in ("decide", "auth", "flow"):
142
+ self.assertIn(word, terms)
134
143
 
135
144
  def test_no_channel_substring_or_attrs_in_query(self):
136
145
  client = _RecordingClient(memories=[_memory("m")])
@@ -212,8 +221,16 @@ class MultiTurnComposedEnvelopeStrip(unittest.TestCase):
212
221
  q = client.queries[0]
213
222
  self.assertNotIn("<channel", q)
214
223
  self.assertNotIn("chat_id", q)
215
- self.assertIn("Prior context:", q)
216
- self.assertTrue(q.rstrip().endswith(BARE))
224
+ # #3757: the "Prior context:" header is scaffolding, not content —
225
+ # it is no longer on the wire (it cost two BM25 terms and matched
226
+ # a large fraction of every bank). What must survive is the prior
227
+ # turns' CONTENT, which is what this test actually guards.
228
+ self.assertNotIn("Prior context:", q)
229
+ self.assertIn("ORCHID", q)
230
+ self.assertIn("ORCHID_PRIMARY", q)
231
+ # The latest turn's content words are the tail of the wire query
232
+ # (shaping preserves source order and drops only stopwords here).
233
+ self.assertTrue(q.rstrip().endswith("decide auth flow"), q)
217
234
  finally:
218
235
  shutil.rmtree(tmpdir, ignore_errors=True)
219
236