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,241 @@
1
+ """Switchroom: the per-bank recall request timeout is a MANAGED KEY, not a literal.
2
+
3
+ `recall.py` used to pass a bare `timeout=8` to every `client.recall()` call.
4
+ That literal lived inside the vendored plugin tree, which `switchroom apply`
5
+ rm's and re-copies — so an operator who needed a different value had to
6
+ hand-edit the installed plugin and watch the next apply silently revert it. The
7
+ live fleet did exactly that, three separate times, each time quietly restoring
8
+ shipped defaults while switchroom.yaml read as though it were tuned.
9
+
10
+ It is now `recallRequestTimeoutSeconds` (env:
11
+ HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS), resolved from
12
+ `memory.recall.request_timeout_seconds` and stamped/exported by switchroom.
13
+
14
+ The assertions here are on the VALUE THAT REACHES THE CLIENT, because that is
15
+ the thing the bug got wrong. Asserting only that the config key parses would
16
+ stay green with the literal still hard-coded at the callsite.
17
+
18
+ Stdlib-only (unittest); runs under ``python3 -m unittest discover tests/``
19
+ from ``scripts/``.
20
+ """
21
+
22
+ import io
23
+ import json
24
+ import os
25
+ import shutil
26
+ import sys
27
+ import tempfile
28
+ import unittest
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.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
37
+
38
+ BARE = "what did we decide about the auth flow last week"
39
+
40
+
41
+ class _RecordingClient:
42
+ """Captures the `timeout` kwarg of every recall() call."""
43
+
44
+ def __init__(self):
45
+ self.timeouts = []
46
+
47
+ def recall(self, **kwargs):
48
+ self.timeouts.append(kwargs.get("timeout"))
49
+ return {"memories": []}
50
+
51
+ def list_directives(self, *a, **kw):
52
+ return []
53
+
54
+
55
+ class _Harness(unittest.TestCase):
56
+ def setUp(self):
57
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-req-timeout-test-")
58
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
59
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
60
+
61
+ def tearDown(self):
62
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
63
+ if self._prev is None:
64
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
65
+ else:
66
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
67
+
68
+ def _timeouts(self, config_extra=None):
69
+ client = _RecordingClient()
70
+ hook_input = {
71
+ "prompt": BARE,
72
+ "session_id": "test-session",
73
+ "transcript_path": "",
74
+ "cwd": "/tmp",
75
+ }
76
+ config = {
77
+ "autoRecall": True,
78
+ "bankId": "own-bank",
79
+ "recallMaxTokens": 1024,
80
+ "recallBudget": "mid",
81
+ "recallContextTurns": 1,
82
+ "recallMaxQueryChars": 800,
83
+ "recallPromptPreamble": "",
84
+ }
85
+ if config_extra:
86
+ config.update(config_extra)
87
+ with patch.object(recall, "load_config", return_value=config), patch.object(
88
+ recall, "get_api_url", return_value="http://localhost:18888"
89
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
90
+ recall, "ensure_bank_mission", return_value=None
91
+ ), patch.object(recall, "write_state", return_value=None), patch(
92
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
93
+ ), patch("sys.stdout", new=io.StringIO()), patch(
94
+ "sys.stderr", new=io.StringIO()
95
+ ):
96
+ recall.main()
97
+ return client.timeouts
98
+
99
+
100
+ class PerBankTimeoutIsConfigured(_Harness):
101
+ def test_default_matches_the_shipped_default(self):
102
+ # Promotion to a managed key must not change what switchroom ships:
103
+ # with the key absent from config, the value reaching the client is
104
+ # exactly DEFAULTS["recallRequestTimeoutSeconds"] — not the client
105
+ # library's own default, and not None.
106
+ self.assertEqual(
107
+ self._timeouts(), [float(DEFAULTS["recallRequestTimeoutSeconds"])]
108
+ )
109
+
110
+ def test_configured_value_reaches_the_client(self):
111
+ self.assertEqual(
112
+ self._timeouts({"recallRequestTimeoutSeconds": 13}), [13.0]
113
+ )
114
+
115
+ def test_applies_to_every_bank_in_a_fan_out(self):
116
+ # One hung bank must not be able to outlive its siblings' budget, so
117
+ # the value has to reach EVERY bank, not just the own-bank slot.
118
+ got = self._timeouts(
119
+ {
120
+ "recallRequestTimeoutSeconds": 5,
121
+ "recallAdditionalBanks": ["shared-a", "shared-b"],
122
+ }
123
+ )
124
+ self.assertEqual(len(got), 3)
125
+ self.assertEqual(set(got), {5.0})
126
+
127
+ def test_applies_on_the_serial_rollback_path_too(self):
128
+ got = self._timeouts(
129
+ {"recallRequestTimeoutSeconds": 7, "recallParallel": False}
130
+ )
131
+ self.assertEqual(got, [7.0])
132
+
133
+ def test_unusable_values_fall_back_instead_of_breaking_recall(self):
134
+ # A malformed or non-positive timeout must not take the pre-turn hook
135
+ # down to a traceback, and must never become "fail instantly".
136
+ for bad in ("not-a-number", None, 0, -3):
137
+ with self.subTest(bad=bad):
138
+ self.assertEqual(
139
+ self._timeouts({"recallRequestTimeoutSeconds": bad}),
140
+ [float(DEFAULTS["recallRequestTimeoutSeconds"])],
141
+ )
142
+
143
+
144
+ class ManagedKeyIsWired(unittest.TestCase):
145
+ def test_key_has_a_default_and_an_env_override(self):
146
+ # Both halves are required for the key to be settable declaratively:
147
+ # the default is the shipped value, the env override is what lets
148
+ # switchroom.yaml outrank a stale ~/.hindsight/claude-code.json.
149
+ # 12s, raised from the historical 8 by #3760 after measuring that 8s
150
+ # fired on 96.8% of one agent's own-bank recalls and returned nothing.
151
+ # Pinned as a literal on purpose: src/setup/hindsight-recall-tunables.ts
152
+ # carries the same number for the env export, which OUTRANKS this file,
153
+ # and hindsight-reranker-budget.test.ts fails CI if the two drift.
154
+ self.assertEqual(DEFAULTS["recallRequestTimeoutSeconds"], 12)
155
+ self.assertIn("HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS", ENV_OVERRIDES)
156
+ key, typ = ENV_OVERRIDES["HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS"]
157
+ self.assertEqual(key, "recallRequestTimeoutSeconds")
158
+ # int, matching the sibling HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS
159
+ # override and switchroom's zod schema, which declares
160
+ # `memory.recall.request_timeout_seconds` as `.int()`
161
+ # (src/config/schema.ts). A float cast here would accept a value the
162
+ # schema can never produce; an int cast against a float-typed schema
163
+ # would silently DROP the override (`_cast_env` returns None and the
164
+ # assignment is skipped), which is the exact silent-fallthrough this
165
+ # test class exists to prevent.
166
+ self.assertIs(typ, int)
167
+
168
+ def test_env_override_wins_over_the_built_in_default(self):
169
+ with patch.dict(
170
+ os.environ,
171
+ {"HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS": "11"},
172
+ clear=False,
173
+ ):
174
+ self.assertEqual(load_config()["recallRequestTimeoutSeconds"], 11)
175
+ self.assertNotEqual(
176
+ load_config()["recallRequestTimeoutSeconds"],
177
+ DEFAULTS["recallRequestTimeoutSeconds"],
178
+ )
179
+
180
+
181
+ class EnvBeatsTheUserConfigFile(unittest.TestCase):
182
+ """The env export is the ONLY carrier that outranks ~/.hindsight/claude-code.json.
183
+
184
+ Load order (load_config, lib/config.py:398-455):
185
+
186
+ DEFAULTS -> plugin settings.json -> ~/.hindsight/claude-code.json -> env
187
+
188
+ Switchroom stamps the envelope into the plugin's settings.json too, but
189
+ claude-code.json sits ABOVE that stamp and SURVIVES `switchroom apply` — the
190
+ live fleet had exactly such a file pinning recallParallelDeadlineSeconds to a
191
+ stale 16. A settings-only stamp is therefore silently defeated, which is why
192
+ start.sh exports these two UNCONDITIONALLY. These tests pin that precedence
193
+ so a future "just make it conditional / drop the export" change fails loudly
194
+ instead of quietly handing authority back to a hand-edited file.
195
+ """
196
+
197
+ ENVELOPE = (
198
+ ("HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS", "recallParallelDeadlineSeconds", 17, 16),
199
+ ("HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS", "recallRequestTimeoutSeconds", 13, 99),
200
+ )
201
+
202
+ def setUp(self):
203
+ self._tmp_home = tempfile.mkdtemp(prefix="recall-envelope-home-")
204
+ os.makedirs(os.path.join(self._tmp_home, ".hindsight"), exist_ok=True)
205
+ # Point the plugin-root settings.json probe at an empty dir so only the
206
+ # two layers under test are in play.
207
+ self._tmp_plugin = tempfile.mkdtemp(prefix="recall-envelope-plugin-")
208
+ self._stale = {key: stale for _, key, _, stale in self.ENVELOPE}
209
+ with open(
210
+ os.path.join(self._tmp_home, ".hindsight", "claude-code.json"), "w"
211
+ ) as f:
212
+ json.dump(self._stale, f)
213
+
214
+ def tearDown(self):
215
+ shutil.rmtree(self._tmp_home, ignore_errors=True)
216
+ shutil.rmtree(self._tmp_plugin, ignore_errors=True)
217
+
218
+ def _patched_env(self, extra):
219
+ env = {"HOME": self._tmp_home, "CLAUDE_PLUGIN_ROOT": self._tmp_plugin}
220
+ env.update(extra)
221
+ return patch.dict(os.environ, env, clear=False)
222
+
223
+ def test_a_stale_user_config_wins_when_nothing_is_exported(self):
224
+ # Establishes that the fixture is real: without the exports the stale
225
+ # hand-edit IS authoritative, so the next test proves something.
226
+ for env_name, key, _, stale in self.ENVELOPE:
227
+ with self.subTest(env_name=env_name), self._patched_env({}):
228
+ os.environ.pop(env_name, None)
229
+ self.assertEqual(load_config()[key], stale)
230
+
231
+ def test_the_exported_envelope_overrides_a_stale_user_config(self):
232
+ exports = {env_name: str(want) for env_name, _, want, _ in self.ENVELOPE}
233
+ with self._patched_env(exports):
234
+ config = load_config()
235
+ for _, key, want, stale in self.ENVELOPE:
236
+ self.assertEqual(config[key], want)
237
+ self.assertNotEqual(config[key], stale)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ unittest.main()
@@ -12,10 +12,16 @@ Acceptance guarantees (outcomes, not code paths):
12
12
  suppresses the fallback entirely (no fallback block, log False) — the
13
13
  fact layer is not empty.
14
14
 
15
- 3. **Does NOT fire when a bank hit its deadline.** deadline_hit True (a bank
16
- abandoned at the shared deadline) means the empty result set may be a
17
- TIMEOUT, not a genuinely empty fact layer the #3369 sequencing
18
- constraint so the fallback is suppressed even though results are empty.
15
+ 3. **DOES fire when a bank hit its deadline** (switchroom #3757 inverted
16
+ from the original #3369 gate). The original rule suppressed the fallback
17
+ on ``deadline_hit`` on the theory that a timeout leaves the fact layer
18
+ unknown. In production a timeout was the COMMON case (96.8% of the
19
+ `overlord` agent's own-bank recalls over the 7 days to 2026-07-27), so the
20
+ rule meant a timed-out turn got neither memories NOR fallback — the agent
21
+ went in blind. A timeout is not an outage: the banks are healthy, we just
22
+ ran out of time, and a bounded transcript grep beats nothing. A hard bank
23
+ ERROR still suppresses it (guarantee 3b) because an unreachable store CAN
24
+ masquerade as an empty fact layer.
19
25
 
20
26
  4. **Byte bound holds.** With a small ``…MaxBytes``, only the transcript TAIL
21
27
  is read: a query-matching line that lives only in the (discarded) HEAD is
@@ -249,8 +255,15 @@ class DoesNotFireWhenFactLayerNotEmpty(_Harness):
249
255
  self.assertEqual(e["result_count"], 1)
250
256
 
251
257
 
252
- class DoesNotFireWhenDeadlineHit(_Harness):
253
- def test_timed_out_bank_suppresses_fallback(self):
258
+ class FiresWhenDeadlineHit(_Harness):
259
+ """Switchroom #3757 — a TIMEOUT must not cost the turn its fallback too.
260
+
261
+ This is the exact inversion of the original #3369 gate. Before the fix a
262
+ timed-out recall produced neither memories nor fallback, which on the
263
+ fleet's large banks was the majority of turns.
264
+ """
265
+
266
+ def test_timed_out_bank_still_fires_fallback(self):
254
267
  transcript = self._write_transcript([
255
268
  _nested_line("user", "we should redo the auth flow with PKCE"),
256
269
  ])
@@ -268,10 +281,14 @@ class DoesNotFireWhenDeadlineHit(_Harness):
268
281
  "recallParallelDeadlineSeconds": 0.5,
269
282
  },
270
283
  )
271
- self.assertIsNone(context, "fallback must not fire when a bank timed out")
284
+ self.assertIsNotNone(
285
+ context, "a timed-out recall must still get the transcript fallback"
286
+ )
287
+ self.assertIn("<hindsight_transcript_fallback>", context)
288
+ self.assertIn("PKCE", context)
272
289
  e = self._read_log()[0]
273
290
  self.assertTrue(e["deadline_hit"])
274
- self.assertFalse(e["transcript_fallback"])
291
+ self.assertTrue(e["transcript_fallback"])
275
292
 
276
293
 
277
294
  class DoesNotFireWhenBankErrored(_Harness):
@@ -7,13 +7,16 @@ import pytest
7
7
  from lib.content import (
8
8
  _extract_text_content,
9
9
  _is_channel_message_tool,
10
+ _selectivity_score,
10
11
  compose_recall_query,
11
12
  format_current_time,
12
13
  format_memories,
13
14
  prepare_retention_transcript,
15
+ shape_recall_query,
14
16
  slice_last_turns_by_user_boundary,
15
17
  strip_channel_envelope,
16
18
  strip_memory_tags,
19
+ tokenize_for_bm25,
17
20
  truncate_recall_query,
18
21
  )
19
22
 
@@ -648,3 +651,218 @@ class TestFormatCurrentTime:
648
651
  monkeypatch.delenv("TZ", raising=False)
649
652
  out = format_current_time()
650
653
  assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM)", out), out
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Switchroom #3757 — BM25 query shaping
658
+ #
659
+ # The bug these guard: the recall hook composed an 800-char, ~110-token query
660
+ # and Hindsight OR-joined every token into one tsquery. On the live `overlord`
661
+ # bank that matched 119,510 of 135,565 rows and took 14.0s for the 3-arm BM25
662
+ # UNION — past the per-bank client timeout, so 96.8% of that agent's own-bank
663
+ # recalls returned NOTHING. Two of those tokens were scaffolding this hook
664
+ # added itself: `user` (67,363 rows = 50% of the bank) and `assistant`
665
+ # (29,942 = 22%).
666
+ # ---------------------------------------------------------------------------
667
+
668
+
669
+ def _production_shaped_query():
670
+ """A composed query with the shape recall.py actually produces."""
671
+ msgs = _msgs(
672
+ ("user", "the deploy went out this morning but the rollout never reached the fleet"),
673
+ (
674
+ "assistant",
675
+ "I checked the manifest for v0.19.17 and the digest does not match what "
676
+ "the agent container is running, so the pull raced the tag",
677
+ ),
678
+ )
679
+ return compose_recall_query(
680
+ "why did recall for the v0.19.17 rollout return v0.18.15 instead",
681
+ msgs,
682
+ recall_context_turns=2,
683
+ )
684
+
685
+
686
+ class TestComposedQueryHasNoRoleLabels:
687
+ def test_compose_does_not_prefix_role_labels(self):
688
+ # The regression this guards: `context_lines.append(f"{role}: {content}")`.
689
+ msgs = _msgs(("user", "prior question"), ("assistant", "prior answer"))
690
+ result = compose_recall_query("current question", msgs, recall_context_turns=2)
691
+ assert "prior question" in result
692
+ assert "prior answer" in result
693
+ assert "user:" not in result
694
+ assert "assistant:" not in result
695
+
696
+ def test_role_labels_are_not_bm25_tokens(self):
697
+ # The property that actually matters — not "the string is absent" but
698
+ # "the term never reaches the tsquery".
699
+ query = _production_shaped_query()
700
+ shaped = shape_recall_query(query, "why did recall for the v0.19.17 rollout "
701
+ "return v0.18.15 instead")
702
+ terms = set(tokenize_for_bm25(shaped))
703
+ assert "user" not in terms
704
+ assert "assistant" not in terms
705
+ assert "prior" not in terms
706
+ assert "context" not in terms
707
+
708
+ def test_labels_embedded_in_transcript_text_are_still_stripped(self):
709
+ # Defence in depth: an old composed string, or a turn that literally
710
+ # contains a `user:` line, must not smuggle the label back in.
711
+ legacy = "Prior context:\n\nuser: the deploy failed\nassistant: I checked it\n\nwhy"
712
+ terms = set(tokenize_for_bm25(shape_recall_query(legacy, "why")))
713
+ assert "user" not in terms
714
+ assert "assistant" not in terms
715
+ assert "deploy" in terms
716
+
717
+
718
+ class TestShapeRecallQuery:
719
+ def test_caps_distinct_bm25_terms(self):
720
+ query = " ".join(f"distinctword{i}" for i in range(200))
721
+ shaped = shape_recall_query(query, "", max_tokens=24)
722
+ assert len(set(tokenize_for_bm25(shaped))) <= 24
723
+
724
+ def test_cap_counts_compound_token_expansion(self):
725
+ # A compound is emitted by the server ALONGSIDE its fragments
726
+ # (`v0.19.17` → v0.19.17, v0, 19, 17), so a naive "count the words we
727
+ # sent" cap would silently ship 4x the terms it promised.
728
+ query = " ".join(f"v0.19.{i}" for i in range(40))
729
+ shaped = shape_recall_query(query, "", max_tokens=24)
730
+ assert len(set(tokenize_for_bm25(shaped))) <= 24
731
+
732
+ def test_cap_is_configurable(self):
733
+ query = " ".join(f"distinctword{i}" for i in range(200))
734
+ assert len(set(tokenize_for_bm25(shape_recall_query(query, "", max_tokens=8)))) <= 8
735
+ assert len(set(tokenize_for_bm25(shape_recall_query(query, "", max_tokens=40)))) <= 40
736
+
737
+ def test_zero_disables_shaping(self):
738
+ query = "Prior context:\n\nsomething\n\nlatest"
739
+ assert shape_recall_query(query, "latest", max_tokens=0) == query
740
+
741
+ def test_prefers_latest_turn_over_prior_context(self):
742
+ # A chronological truncation would keep the OLDEST context and throw
743
+ # away the question the user actually asked, so the latest turn takes
744
+ # the MAJORITY of the budget. But recency is a weight plus a bounded
745
+ # quota, not an absolute tier (#3760 review, Blocker 2): the subject of
746
+ # a conversation routinely sits in the turn BEFORE the one that refers
747
+ # to it as "it"/"that", and an absolute tier starves it every time.
748
+ prior = " ".join(f"stalecontextword{i}" for i in range(60))
749
+ latest = "why does the reaper skip orphaned worktrees"
750
+ query = f"Prior context:\n\n{prior}\n\n{latest}"
751
+ # Every prior token here is strictly MORE selective than anything in
752
+ # the question (they carry digits; the question is plain English), so
753
+ # pure merit ordering would hand prior context all four slots and the
754
+ # user would search for none of what they asked.
755
+ assert _selectivity_score("stalecontextword0") > _selectivity_score("worktrees")
756
+ # The latest-turn reserve is what stops that. At a punishing cap it is
757
+ # only `max_tokens // 3` slots — the question is represented, not
758
+ # preserved whole, which is the honest tradeoff when prior context is
759
+ # genuinely more discriminating.
760
+ tight = tokenize_for_bm25(shape_recall_query(query, latest, max_tokens=4))
761
+ from_question = set(tight) & {"reaper", "skip", "orphaned", "worktrees"}
762
+ assert len(from_question) >= max(1, 4 // 3)
763
+ assert len(set(tight)) <= 4
764
+ # With slack, the latest turn is still fully present and the leftover
765
+ # budget goes to context (which is the point of composing at all).
766
+ loose = set(tokenize_for_bm25(shape_recall_query(query, latest, max_tokens=12)))
767
+ assert {"reaper", "skip", "orphaned", "worktrees"} <= loose
768
+ assert any(t.startswith("stalecontextword") for t in loose)
769
+ assert len(loose) <= 12
770
+
771
+ def test_drops_english_stopwords(self):
772
+ latest = "what did we decide about the worktree reaper"
773
+ terms = set(tokenize_for_bm25(shape_recall_query(latest, latest, max_tokens=24)))
774
+ assert "worktree" in terms
775
+ assert "reaper" in terms
776
+ assert "decide" in terms
777
+ for stop in ("what", "did", "we", "about", "the"):
778
+ assert stop not in terms
779
+
780
+ def test_operator_stop_terms_are_dropped(self):
781
+ # Bank-specific high-df words the generic stoplist cannot know about.
782
+ latest = "the switchroom agent rollout stalled on the reaper"
783
+ terms = set(
784
+ tokenize_for_bm25(
785
+ shape_recall_query(latest, latest, max_tokens=24,
786
+ stop_terms=["switchroom", "agent"])
787
+ )
788
+ )
789
+ assert "reaper" in terms
790
+ assert "rollout" in terms
791
+ assert "switchroom" not in terms
792
+ assert "agent" not in terms
793
+
794
+ def test_short_query_survives_intact(self):
795
+ # No regression for the small-bank / short-prompt case: every content
796
+ # word is kept, so recall quality is unchanged.
797
+ latest = "worktree gc reaper timings"
798
+ terms = set(tokenize_for_bm25(shape_recall_query(latest, latest, max_tokens=24)))
799
+ assert terms == {"worktree", "gc", "reaper", "timings"}
800
+
801
+ def test_all_stopword_query_is_not_emptied(self):
802
+ # A conversational prompt must never be shaped down to nothing — an
803
+ # empty query would return zero memories, the exact failure we are
804
+ # fixing.
805
+ latest = "what about that"
806
+ shaped = shape_recall_query(latest, latest, max_tokens=24)
807
+ assert tokenize_for_bm25(shaped)
808
+
809
+ def test_untokenizable_query_returns_original(self):
810
+ assert shape_recall_query("!!! ???", "!!! ???", max_tokens=24) == "!!! ???"
811
+
812
+ def test_preserves_original_word_order(self):
813
+ latest = "reaper skipped orphaned worktrees before rollout"
814
+ shaped = shape_recall_query(latest, latest, max_tokens=24)
815
+ # "before" is a stopword; the survivors keep the source order.
816
+ assert shaped.split() == ["reaper", "skipped", "orphaned", "worktrees", "rollout"]
817
+
818
+ def test_unparseable_cap_disables_shaping_instead_of_raising(self):
819
+ # max_tokens arrives from settings.json / env. A config error must
820
+ # degrade to "send it unshaped", never raise on the recall hot path.
821
+ latest = "the reaper skipped orphaned worktrees before the rollout"
822
+ for bad in (None, "x", "", [], {}):
823
+ assert shape_recall_query(latest, latest, max_tokens=bad) == latest
824
+
825
+ def test_numeric_string_cap_is_honoured(self):
826
+ latest = "the reaper skipped orphaned worktrees before the rollout"
827
+ shaped = shape_recall_query(latest, latest, max_tokens="3")
828
+ assert len(set(tokenize_for_bm25(shaped))) <= 3
829
+
830
+ def test_stop_terms_given_as_a_bare_string_are_split_not_iterated(self):
831
+ # "reaper,worktrees" iterated as characters would stop-list half the
832
+ # alphabet and gut the query.
833
+ latest = "the reaper skipped orphaned worktrees before the rollout"
834
+ shaped = shape_recall_query(latest, latest, 24, stop_terms="reaper, worktrees")
835
+ terms = set(tokenize_for_bm25(shaped))
836
+ assert "reaper" not in terms
837
+ assert "worktrees" not in terms
838
+ assert {"skipped", "orphaned", "rollout"} <= terms
839
+
840
+ def test_preserves_original_case(self):
841
+ # The shaped string feeds BOTH arms. BM25 lowercases server-side, but
842
+ # the embedding arm does not, so shaping must not flatten `Python` to
843
+ # `python` or `PR` to `pr`.
844
+ latest = "should the Python worker open a PR against Coolify"
845
+ shaped = shape_recall_query(latest, latest, max_tokens=24)
846
+ assert shaped.split() == ["Python", "worker", "open", "PR", "Coolify"]
847
+
848
+ def test_production_shaped_query_fits_the_budget(self):
849
+ query = _production_shaped_query()
850
+ latest = "why did recall for the v0.19.17 rollout return v0.18.15 instead"
851
+ shaped = shape_recall_query(query, latest, max_tokens=24)
852
+ terms = set(tokenize_for_bm25(shaped))
853
+ assert len(terms) <= 24
854
+ # The discriminating identifiers from the latest turn survive.
855
+ assert "v0.19.17" in terms
856
+ assert "rollout" in terms
857
+
858
+
859
+ class TestTokenizeForBm25:
860
+ def test_matches_server_tokenizer_on_compounds(self):
861
+ # Mirrors hindsight_api/engine/search/retrieval.py::tokenize_query —
862
+ # fragments PLUS the intact compound.
863
+ tokens = tokenize_for_bm25("bumped to v0.19.17")
864
+ assert "v0.19.17" in tokens
865
+ assert {"v0", "19", "17"} <= set(tokens)
866
+
867
+ def test_empty_for_punctuation_only(self):
868
+ assert tokenize_for_bm25("!!! ???") == []