switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,365 @@
1
+ """Switchroom #3619 — unit tests for the degraded-recall disclosure.
2
+
3
+ Measured 2026-07-26 across `recall_log.jsonl` fleet-wide: of 1280 rows carrying
4
+ `bank_timings`, 1148 (89.7%) had the agent's OWN bank time out, and only 28 of
5
+ those still returned any memories. That ran for weeks without anyone noticing,
6
+ because a degraded recall and an empty-but-healthy recall were byte-identical
7
+ from the agent's side: both emit nothing. The agent then answers from no
8
+ context while its CLAUDE.md tells it recall "auto-fires on every inbound
9
+ message", so it asserts "no prior context" rather than "I could not check".
10
+
11
+ `degraded_recall_notice` closes that. These tests pin the behaviour that makes
12
+ it trustworthy rather than noisy:
13
+
14
+ * It FIRES when the agent's own bank timed out or hard-errored.
15
+ * It does NOT fire for a side-bank-only failure — a shared profile bank
16
+ timing out does not mean the agent lost its own memory, and a notice that
17
+ cries wolf gets ignored exactly when it matters.
18
+ * It does NOT fire on a healthy-but-empty bank (the "you genuinely have no
19
+ relevant memories" turn), which is the whole distinction being drawn.
20
+ * The own bank is matched by `bank_id`, NEVER by position — fan-out
21
+ completion order is not the source of `bank_timings` order and a
22
+ positional match would silently report the wrong bank's health.
23
+ * It is emitted OUTSIDE the cached context: `_combine_context` puts it
24
+ first, and the cached `context_message` must never contain it, or a later
25
+ healthy cache hit replays a stale "recall was DEGRADED" notice.
26
+
27
+ Stdlib-only, no network, no server.
28
+ """
29
+
30
+ import io
31
+ import json
32
+ import os
33
+ import shutil
34
+ import socket
35
+ import sys
36
+ import tempfile
37
+ import unittest
38
+ from unittest.mock import patch
39
+
40
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
41
+ if SCRIPTS_DIR not in sys.path:
42
+ sys.path.insert(0, SCRIPTS_DIR)
43
+
44
+ import recall # noqa: E402
45
+ from recall import ( # noqa: E402
46
+ _combine_context,
47
+ degraded_recall_notice,
48
+ )
49
+
50
+ OWN = "overlord"
51
+ SIDE = "ken-profile"
52
+
53
+
54
+ def timing(bank_id, *, timed_out=False, errored=False, elapsed_ms=120):
55
+ """A `bank_timings` entry in the exact shape recall.py appends at
56
+ recall.py:1809 (parallel path) and recall.py:1851 (serial path)."""
57
+ return {
58
+ "bank_id": bank_id,
59
+ "elapsed_ms": elapsed_ms,
60
+ "timed_out": timed_out,
61
+ "errored": errored,
62
+ }
63
+
64
+
65
+ class TestDegradedNoticeFires(unittest.TestCase):
66
+ def test_fires_when_own_bank_times_out(self):
67
+ notice = degraded_recall_notice(
68
+ OWN, [timing(OWN, timed_out=True, elapsed_ms=8003)]
69
+ )
70
+ self.assertIn("DEGRADED", notice)
71
+ self.assertIn("timed out", notice)
72
+ # The bank must be named — a generic notice gives the operator nothing
73
+ # to grep for when three agents share a topic.
74
+ self.assertIn(OWN, notice)
75
+
76
+ def test_fires_when_own_bank_hard_errors(self):
77
+ notice = degraded_recall_notice(OWN, [timing(OWN, errored=True)])
78
+ self.assertIn("DEGRADED", notice)
79
+ self.assertIn("was unreachable", notice)
80
+ self.assertNotIn("timed out", notice)
81
+
82
+ def test_fires_even_when_side_bank_returned_results(self):
83
+ """The masking case that hid this for weeks: the own bank times out,
84
+ the smaller shared bank answers, so the turn LOOKS like a successful
85
+ recall (non-zero result_count) while the agent's own memory is gone."""
86
+ notice = degraded_recall_notice(
87
+ OWN,
88
+ [timing(OWN, timed_out=True, elapsed_ms=8001), timing(SIDE, elapsed_ms=940)],
89
+ )
90
+ self.assertIn("DEGRADED", notice)
91
+
92
+ def test_tells_the_agent_to_treat_absence_as_unknown(self):
93
+ """The notice exists to change the agent's BEHAVIOUR on a degraded
94
+ turn. If it stops saying so, it is decoration."""
95
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
96
+ self.assertIn("UNKNOWN", notice)
97
+
98
+ def test_is_a_single_short_line(self):
99
+ """Fires on an already-starved turn; a verbose block would spend the
100
+ very budget the degradation is starving."""
101
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
102
+ self.assertNotIn("\n", notice)
103
+ self.assertLess(len(notice), 500)
104
+
105
+
106
+ class TestDegradedNoticeSilent(unittest.TestCase):
107
+ def test_silent_when_own_bank_healthy_but_empty(self):
108
+ """The distinction the whole feature draws: a healthy bank with nothing
109
+ relevant must stay silent, or the notice fires on ordinary turns and
110
+ gets tuned out."""
111
+ self.assertEqual(degraded_recall_notice(OWN, [timing(OWN)]), "")
112
+
113
+ def test_silent_when_only_a_side_bank_fails(self):
114
+ self.assertEqual(
115
+ degraded_recall_notice(
116
+ OWN, [timing(OWN, elapsed_ms=310), timing(SIDE, timed_out=True)]
117
+ ),
118
+ "",
119
+ )
120
+
121
+ def test_silent_when_only_a_side_bank_hard_errors(self):
122
+ self.assertEqual(
123
+ degraded_recall_notice(OWN, [timing(OWN), timing(SIDE, errored=True)]),
124
+ "",
125
+ )
126
+
127
+ def test_silent_on_empty_or_missing_timings(self):
128
+ # Cache-hit and trivial-skip turns take an early return with no
129
+ # fan-out; nothing was attempted, so nothing is degraded.
130
+ self.assertEqual(degraded_recall_notice(OWN, []), "")
131
+ self.assertEqual(degraded_recall_notice(OWN, None), "")
132
+ self.assertEqual(degraded_recall_notice("", [timing(OWN, timed_out=True)]), "")
133
+
134
+ def test_silent_when_own_bank_absent_from_timings(self):
135
+ self.assertEqual(
136
+ degraded_recall_notice(OWN, [timing(SIDE, timed_out=True)]), ""
137
+ )
138
+
139
+ def test_tolerates_malformed_timing_entries(self):
140
+ """`bank_timings` is telemetry read back into a decision; a stray
141
+ non-dict must not take the whole hook down with a TypeError — the hook
142
+ failing is strictly worse than the notice not firing."""
143
+ self.assertEqual(
144
+ degraded_recall_notice(OWN, ["junk", None, timing(OWN)]), ""
145
+ )
146
+ self.assertIn(
147
+ "DEGRADED",
148
+ degraded_recall_notice(OWN, ["junk", timing(OWN, timed_out=True)]),
149
+ )
150
+
151
+
152
+ class TestOwnBankMatchedById(unittest.TestCase):
153
+ def test_matches_own_bank_by_id_not_position(self):
154
+ """Own-bank-first is the CONSTRUCTION order of bank_specs
155
+ (recall.py:1716), not a guarantee a future refactor preserves. A
156
+ positional [0] match here would report the side bank's health as the
157
+ agent's own — silently, and in the direction of a false all-clear."""
158
+ timings = [timing(SIDE, elapsed_ms=900), timing(OWN, timed_out=True)]
159
+ notice = degraded_recall_notice(OWN, timings)
160
+ self.assertIn("DEGRADED", notice)
161
+ self.assertIn(OWN, notice)
162
+ self.assertNotIn(SIDE, notice)
163
+
164
+ def test_a_healthy_own_bank_listed_second_stays_silent(self):
165
+ timings = [timing(SIDE, timed_out=True), timing(OWN, elapsed_ms=400)]
166
+ self.assertEqual(degraded_recall_notice(OWN, timings), "")
167
+
168
+
169
+ class TestNoticeIsNotCached(unittest.TestCase):
170
+ """The notice is per-turn state. `_combine_context`'s contract (recall.py
171
+ :1289) is that transient blocks are appended at EMIT time and kept out of
172
+ the string that reaches `_cache_store` / LAST_RECALL_STATE."""
173
+
174
+ def test_combine_puts_the_notice_first(self):
175
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
176
+ context = "## Relevant memories\n- something"
177
+ combined = _combine_context(_combine_context(notice, context), "")
178
+ # First, because it changes how everything after it should be read.
179
+ self.assertTrue(combined.startswith(notice))
180
+ self.assertIn(context, combined)
181
+
182
+ def test_combine_with_notice_and_nudge_keeps_both(self):
183
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
184
+ context = "## Relevant memories\n- something"
185
+ nudge = "[Hindsight] consider create_directive"
186
+ combined = _combine_context(_combine_context(notice, context), nudge)
187
+ self.assertTrue(combined.startswith(notice))
188
+ self.assertIn(context, combined)
189
+ self.assertTrue(combined.endswith(nudge))
190
+
191
+ def test_healthy_turn_combines_to_exactly_the_context(self):
192
+ """No notice ⇒ emitted context is byte-identical to the cached one, so
193
+ a healthy turn is unchanged by this feature."""
194
+ notice = degraded_recall_notice(OWN, [timing(OWN)])
195
+ context = "## Relevant memories\n- something"
196
+ self.assertEqual(_combine_context(_combine_context(notice, context), ""), context)
197
+
198
+ def test_cached_context_source_excludes_the_notice(self):
199
+ """Regression guard on the actual defect this could reintroduce:
200
+ if `degraded_block` were folded into `context_message`, that string is
201
+ what gets written to the cache (recall.py `_cache_store(cache_key,
202
+ context_message)`) and to LAST_RECALL_STATE — so the next HEALTHY turn
203
+ served from cache would replay "recall was DEGRADED" and the agent
204
+ would hedge for no reason."""
205
+ source = os.path.join(SCRIPTS_DIR, "recall.py")
206
+ with open(source, "r", encoding="utf-8") as fh:
207
+ body = fh.read()
208
+ # The composition of context_message must not reference degraded_block.
209
+ # Anchor on the assignment itself and the cache write, so a mutation
210
+ # that ADDS degraded_block to `parts` fails on the assertion below
211
+ # rather than on a missing anchor.
212
+ start_anchor = 'context_message = "\\n\\n".join(parts)'
213
+ end_anchor = "_cache_store(cache_key, context_message)"
214
+ self.assertIn(start_anchor, body, "context_message composition not found")
215
+ self.assertIn(end_anchor, body, "recall cache write not found")
216
+ # Walk back to the start of the `parts` accumulation.
217
+ start = body.rindex("parts = []", 0, body.index(start_anchor))
218
+ end = body.index(end_anchor)
219
+ self.assertNotIn(
220
+ "degraded_block",
221
+ body[start:end],
222
+ "degraded_block must not reach context_message: that string is "
223
+ "cached and would replay a stale DEGRADED notice on a healthy hit",
224
+ )
225
+ # And it must still reach the emitted additionalContext — the final
226
+ # emit site, which is the one after the cache write.
227
+ emit_at = body.index('"additionalContext"', end)
228
+ self.assertIn("degraded_block", body[emit_at : emit_at + 400])
229
+
230
+
231
+ def _memory(text, mem_id):
232
+ return {"text": text, "type": "fact", "mentioned_at": "2026-01-01", "id": mem_id}
233
+
234
+
235
+ class _Client:
236
+ """Fake HindsightClient with per-bank results / exceptions."""
237
+
238
+ def __init__(self, bank_results=None, bank_errors=None):
239
+ self._bank_results = bank_results or {}
240
+ self._bank_errors = bank_errors or {}
241
+
242
+ def list_directives(self, bank_id, active_only=True, timeout=2):
243
+ return {"items": []}
244
+
245
+ def recall(self, bank_id, query, **kwargs):
246
+ exc = self._bank_errors.get(bank_id)
247
+ if exc is not None:
248
+ raise exc
249
+ return {"results": list(self._bank_results.get(bank_id, []))}
250
+
251
+
252
+ class TestEndToEndEmit(unittest.TestCase):
253
+ """Drive `recall.main()` end to end. The unit tests above pin the helper;
254
+ these pin that the notice actually REACHES `additionalContext`, which is
255
+ the only thing the agent ever sees."""
256
+
257
+ OWN = "test-bank"
258
+ SIDE = "shared-bank"
259
+
260
+ def setUp(self):
261
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-degraded-test-")
262
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
263
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
264
+
265
+ def tearDown(self):
266
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
267
+ if self._prev is None:
268
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
269
+ else:
270
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
271
+
272
+ def _run(self, client, config_extra=None):
273
+ hook_input = {
274
+ "prompt": "what did we decide about the auth flow",
275
+ "session_id": "test-session",
276
+ "transcript_path": "",
277
+ "cwd": "/tmp",
278
+ }
279
+ config = {
280
+ "autoRecall": True,
281
+ "bankId": self.OWN,
282
+ "recallMaxTokens": 1024,
283
+ "recallBudget": "mid",
284
+ "recallContextTurns": 1,
285
+ "recallMaxQueryChars": 800,
286
+ "recallPromptPreamble": "",
287
+ "recallParallelDeadlineSeconds": 5,
288
+ }
289
+ if config_extra:
290
+ config.update(config_extra)
291
+ stdout = io.StringIO()
292
+ stderr = io.StringIO()
293
+ self._cached = []
294
+ with patch.object(recall, "load_config", return_value=config), patch.object(
295
+ recall, "get_api_url", return_value="http://localhost:18888"
296
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
297
+ recall, "ensure_bank_mission", return_value=None
298
+ ), patch.object(recall, "write_state", return_value=None), patch.object(
299
+ recall, "_cache_ttl_secs", return_value=300
300
+ ), patch.object(recall, "_cache_lookup", return_value=None), patch.object(
301
+ recall,
302
+ "_cache_store",
303
+ side_effect=lambda key, value: self._cached.append(value),
304
+ ), patch(
305
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
306
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
307
+ recall.main()
308
+ raw = stdout.getvalue()
309
+ if not raw.strip():
310
+ return None
311
+ return json.loads(raw)["hookSpecificOutput"]["additionalContext"]
312
+
313
+ def test_masking_case_notice_rides_above_the_side_bank_memories(self):
314
+ """The exact shape that hid the outage: own bank times out, the smaller
315
+ shared bank answers, so the turn looks successful. The agent must still
316
+ be told its own memory is missing."""
317
+ client = _Client(
318
+ bank_results={self.SIDE: [_memory("ken prefers TypeScript", "m1")]},
319
+ bank_errors={self.OWN: socket.timeout("timed out")},
320
+ )
321
+ ctx = self._run(client, {"recallAdditionalBanks": [self.SIDE]})
322
+ self.assertIsNotNone(ctx)
323
+ self.assertTrue(ctx.startswith("[Hindsight] Memory recall was DEGRADED"))
324
+ self.assertIn(self.OWN, ctx)
325
+ # The side bank's memories are still delivered — the notice qualifies
326
+ # them, it does not suppress them.
327
+ self.assertIn("ken prefers TypeScript", ctx)
328
+
329
+ def test_notice_is_not_written_to_the_recall_cache(self):
330
+ """The cache write must carry the memories WITHOUT the notice, or the
331
+ next healthy turn served from cache replays a stale DEGRADED line."""
332
+ client = _Client(
333
+ bank_results={self.SIDE: [_memory("ken prefers TypeScript", "m1")]},
334
+ bank_errors={self.OWN: socket.timeout("timed out")},
335
+ )
336
+ self._run(client, {"recallAdditionalBanks": [self.SIDE]})
337
+ self.assertEqual(len(self._cached), 1)
338
+ self.assertNotIn("DEGRADED", self._cached[0])
339
+ self.assertIn("ken prefers TypeScript", self._cached[0])
340
+
341
+ def test_healthy_turn_emits_no_notice(self):
342
+ client = _Client(bank_results={self.OWN: [_memory("something", "m1")]})
343
+ ctx = self._run(client)
344
+ self.assertIsNotNone(ctx)
345
+ self.assertNotIn("DEGRADED", ctx)
346
+
347
+ def test_side_bank_only_timeout_emits_no_notice(self):
348
+ client = _Client(
349
+ bank_results={self.OWN: [_memory("something", "m1")]},
350
+ bank_errors={self.SIDE: socket.timeout("timed out")},
351
+ )
352
+ ctx = self._run(client, {"recallAdditionalBanks": [self.SIDE]})
353
+ self.assertIsNotNone(ctx)
354
+ self.assertNotIn("DEGRADED", ctx)
355
+
356
+ def test_total_failure_emits_the_notice_alone(self):
357
+ client = _Client(bank_errors={self.OWN: socket.timeout("timed out")})
358
+ ctx = self._run(client)
359
+ self.assertIsNotNone(ctx)
360
+ self.assertIn("DEGRADED", ctx)
361
+ self.assertNotIn("<hindsight_memories>", ctx)
362
+
363
+
364
+ if __name__ == "__main__":
365
+ unittest.main()
@@ -329,10 +329,18 @@ class TotalFailureStillLogs(_LogTestBase):
329
329
  bank_behaviour={"test-bank": socket.timeout("timed out")},
330
330
  )
331
331
  ctx, raw = _run_main_with(client, prompt=BARE)
332
- # No block injected (no directives, no memories).
333
- self.assertIsNone(ctx)
334
- self.assertEqual(raw.strip(), "")
335
- # …but the telemetry row exists and records the deadline hit.
332
+ # No memories and no directives were injected — there were none to
333
+ # inject. Switchroom #3619: the turn is no longer SILENT, though. The
334
+ # own bank timed out, so the agent gets the one-line degraded
335
+ # disclosure and nothing else previously "empty because the bank
336
+ # timed out" and "empty because the bank held nothing" were
337
+ # indistinguishable to the agent, which is what let a ~90% own-bank
338
+ # timeout rate hide for weeks.
339
+ self.assertIsNotNone(ctx)
340
+ self.assertIn("DEGRADED", ctx)
341
+ self.assertNotIn("<hindsight_memories>", ctx)
342
+ self.assertNotIn("<hindsight_directives>", ctx)
343
+ # …and the telemetry row still exists and records the deadline hit.
336
344
  entries = self._read_log()
337
345
  self.assertEqual(len(entries), 1)
338
346
  e = entries[0]
@@ -167,6 +167,31 @@ class _Harness(unittest.TestCase):
167
167
  context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
168
168
  return context
169
169
 
170
+ def _assert_no_fallback(self, context):
171
+ """Assert the transcript fallback did NOT fire.
172
+
173
+ Switchroom #3619: a degraded own-bank read now emits a one-line
174
+ DEGRADED disclosure, so "the hook emitted nothing at all" is no longer
175
+ a valid proxy for "the fallback did not fire" — assert on the fallback
176
+ block itself. These tests are about fallback GATING, not about whether
177
+ any context was produced.
178
+
179
+ Review follow-up (#3626): this deliberately does NOT tolerate a `None`
180
+ context. Every caller reaches it with the OWN bank hard-errored, which
181
+ is exactly the condition the disclosure must fire on, so `None` means
182
+ the disclosure regressed. An earlier `if context is None: return`
183
+ guard here made the `DEGRADED` assertion vacuous under precisely that
184
+ mutation — stubbing `degraded_block = ""` left all 12 tests in this
185
+ file green.
186
+ """
187
+ self.assertIsNotNone(
188
+ context,
189
+ "own bank hard-errored, so the #3619 degraded disclosure must have "
190
+ "been emitted — a silent turn is the regression this guards",
191
+ )
192
+ self.assertNotIn("<hindsight_transcript_fallback>", context)
193
+ self.assertIn("DEGRADED", context)
194
+
170
195
 
171
196
  class FiresOnEmptyFactLayer(_Harness):
172
197
  def test_fires_when_all_banks_zero_and_no_deadline_hit(self):
@@ -264,7 +289,7 @@ class DoesNotFireWhenBankErrored(_Harness):
264
289
  bank_errors={"own-bank": ConnectionRefusedError("connection refused")},
265
290
  )
266
291
  context = self._run(client, transcript_path=transcript)
267
- self.assertIsNone(context, "fallback must not fire when a bank hard-errored")
292
+ self._assert_no_fallback(context)
268
293
  e = self._read_log()[0]
269
294
  self.assertTrue(e["bank_errored"])
270
295
  self.assertFalse(e["deadline_hit"])
@@ -283,7 +308,7 @@ class DoesNotFireWhenBankErrored(_Harness):
283
308
  transcript_path=transcript,
284
309
  config_extra={"recallParallel": False},
285
310
  )
286
- self.assertIsNone(context, "fallback must not fire when a bank hard-errored")
311
+ self._assert_no_fallback(context)
287
312
  e = self._read_log()[0]
288
313
  self.assertEqual(e["recall_mode"], "serial")
289
314
  self.assertTrue(e["bank_errored"])