switchroom 0.18.24 → 0.18.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 (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -0,0 +1,350 @@
1
+ """Switchroom #3244 — durable-retain / boot-reconciliation outcome tests.
2
+
3
+ Stdlib-only (`python3 -m unittest discover tests/`). Every test drives the real
4
+ hook code against a FAKE in-process daemon (no network, no LLM) and asserts
5
+ OUTCOMES — content landed in the bank / entries landed in the pending queue /
6
+ the watermark's value — not merely that a code path ran.
7
+
8
+ Covers the PR1 slice of design-20260715.md §4: tests 1 (SIGKILL→boot recall),
9
+ 2 (failed Stop retain enqueued), 3 (live-Stop + reconcile ⇒ ONE doc via the
10
+ single deterministic id), 6 (watermark monotonicity + uuid-not-found), 7
11
+ (200-not-persisted / commit-before-ack), 9 (dry-run/seam zero writes), 10 (a
12
+ bound ENQUEUES the remainder).
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tempfile
20
+ import unittest
21
+ from unittest import mock
22
+
23
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
24
+ if SCRIPTS_DIR not in sys.path:
25
+ sys.path.insert(0, SCRIPTS_DIR)
26
+
27
+ import reconcile_tail # noqa: E402
28
+ import retain # noqa: E402
29
+ from lib import watermark # noqa: E402
30
+ from lib.client import HindsightClient # noqa: E402
31
+
32
+
33
+ class FakeDaemon:
34
+ """Records retained documents by document_id with upsert semantics.
35
+
36
+ ``fail`` → every POST raises (stressed/unreachable daemon).
37
+ ``drop_async`` → an ``async_processing=True`` POST 200s but does NOT persist
38
+ (ack-of-receipt, extraction lost) — the #3244 async-200 hazard. An
39
+ ``async_processing=False`` POST always persists (commit-before-ack).
40
+ """
41
+
42
+ def __init__(self):
43
+ self.docs = {} # document_id -> {content, metadata, async}
44
+ self.posts = [] # [(document_id, async_processing)]
45
+ self.fail = False
46
+ self.drop_async = False
47
+
48
+ def retain(self, bank_id, content, document_id="conversation", context=None,
49
+ metadata=None, tags=None, timeout=15, async_processing=True):
50
+ self.posts.append((document_id, async_processing))
51
+ if self.fail:
52
+ raise RuntimeError("simulated daemon failure")
53
+ persisted = (not async_processing) or (not self.drop_async)
54
+ if persisted:
55
+ # Upsert: same document_id overwrites (the daemon contract §1).
56
+ self.docs[document_id] = {
57
+ "content": content, "metadata": metadata, "async": async_processing,
58
+ }
59
+ return {"ok": True}
60
+
61
+ # Any content substring present across all stored docs?
62
+ def content_blob(self):
63
+ return "\n".join(d["content"] for d in self.docs.values())
64
+
65
+
66
+ def _write_transcript(path, n_turns, session_prefix="u"):
67
+ """Flat-format JSONL: n_turns human turns (user+assistant), each with a uuid."""
68
+ lines = []
69
+ for i in range(n_turns):
70
+ lines.append(json.dumps({"role": "user", "content": f"user turn {i}", "uuid": f"{session_prefix}-u{i}"}))
71
+ lines.append(json.dumps({"role": "assistant", "content": f"assistant turn {i}", "uuid": f"{session_prefix}-a{i}"}))
72
+ with open(path, "w", encoding="utf-8") as f:
73
+ f.write("\n".join(lines))
74
+
75
+
76
+ class DurabilityTestBase(unittest.TestCase):
77
+ def setUp(self):
78
+ self.tmp = tempfile.mkdtemp(prefix="hs-3244-")
79
+ self.plugin_root = os.path.join(self.tmp, "plugin_root")
80
+ self.home = os.path.join(self.tmp, "home")
81
+ self.data = os.path.join(self.tmp, "data")
82
+ self.transcripts = os.path.join(self.tmp, "transcripts")
83
+ for d in (self.plugin_root, self.home, self.data, self.transcripts):
84
+ os.makedirs(d)
85
+
86
+ # chunked + n=3 → the deployed default, which exercises the
87
+ # content-derived document_id path (§1). bankMission empty → no mission
88
+ # PATCH. autoRetain on.
89
+ settings = {
90
+ "autoRetain": True,
91
+ "autoRecall": True,
92
+ "retainMode": "chunked",
93
+ "retainEveryNTurns": 3,
94
+ "retainOverlapTurns": 0,
95
+ "bankId": "test-bank",
96
+ "reconcileOnStart": True,
97
+ }
98
+ with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
99
+ json.dump(settings, f)
100
+
101
+ self.env = mock.patch.dict(os.environ, {
102
+ "CLAUDE_PLUGIN_ROOT": self.plugin_root,
103
+ "CLAUDE_PLUGIN_DATA": self.data,
104
+ "HOME": self.home,
105
+ "HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
106
+ "HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
107
+ "HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
108
+ "HINDSIGHT_TRANSCRIPTS_DIR": self.transcripts,
109
+ }, clear=False)
110
+ self.env.start()
111
+ for k in list(os.environ):
112
+ if k.startswith("HINDSIGHT_") and k not in (
113
+ "HINDSIGHT_PENDING_DIR", "HINDSIGHT_RETAINED_DIR",
114
+ "HINDSIGHT_INFLIGHT_LOCK", "HINDSIGHT_TRANSCRIPTS_DIR",
115
+ ):
116
+ os.environ.pop(k, None)
117
+
118
+ self.daemon = FakeDaemon()
119
+ self._patches = [
120
+ mock.patch.object(HindsightClient, "retain", self._fake_retain),
121
+ mock.patch("retain.get_api_url", return_value="http://fake"),
122
+ mock.patch("reconcile_tail.get_api_url", return_value="http://fake"),
123
+ ]
124
+ for p in self._patches:
125
+ p.start()
126
+
127
+ def _fake_retain(self, *a, **kw):
128
+ return self.daemon.retain(*a, **kw)
129
+
130
+ def tearDown(self):
131
+ for p in self._patches:
132
+ p.stop()
133
+ self.env.stop()
134
+ shutil.rmtree(self.tmp, ignore_errors=True)
135
+
136
+ def _config(self):
137
+ from lib.config import load_config
138
+ return load_config()
139
+
140
+ def _pending_entries(self):
141
+ from lib import pending
142
+ return pending.iter_entries()
143
+
144
+
145
+ class TestForwardFix(DurabilityTestBase):
146
+ # -- Test 1: SIGKILL-after-work → next-boot reconcile surfaces it -----------
147
+ def test_sigkill_then_boot_reconcile_recovers_all_turns(self):
148
+ session = "sessA"
149
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
150
+ _write_transcript(tpath, 5, session_prefix=session)
151
+ hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
152
+
153
+ # (b) Stop retains fire but the daemon REFUSES them. run via main() so
154
+ # the A2 enqueue path is exercised.
155
+ self.daemon.fail = True
156
+ with mock.patch("retain.increment_turn_count", return_value=3), \
157
+ mock.patch("sys.stdin", _stdin(hook)):
158
+ retain.main()
159
+
160
+ # watermark did NOT advance (no confirmed persistence) ...
161
+ self.assertIsNone(watermark.load(session))
162
+ # ... and the failed Stop retain was ENQUEUED, not dropped (A2).
163
+ self.assertEqual(len(self._pending_entries()), 1)
164
+
165
+ # (c) No SessionEnd fires (abrupt kill). (d) Boot against a healthy daemon.
166
+ self.daemon.fail = False
167
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
168
+
169
+ # OUTCOME: all 5 turns' content is now in the bank.
170
+ blob = self.daemon.content_blob()
171
+ for i in range(5):
172
+ self.assertIn(f"user turn {i}", blob)
173
+ # And the watermark now anchors on the last committed entry.
174
+ wm = watermark.load(session)
175
+ self.assertIsNotNone(wm)
176
+ self.assertEqual(wm["last_uuid"], f"{session}-a4")
177
+
178
+ # -- Test 2: failed Stop retain is enqueued, not dropped --------------------
179
+ def test_failed_stop_retain_enqueued_with_deterministic_id(self):
180
+ session = "sessB"
181
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
182
+ _write_transcript(tpath, 3, session_prefix=session)
183
+ hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
184
+
185
+ self.daemon.fail = True
186
+ with mock.patch("retain.increment_turn_count", return_value=3), \
187
+ mock.patch("sys.stdin", _stdin(hook)):
188
+ retain.main()
189
+
190
+ entries = self._pending_entries()
191
+ self.assertEqual(len(entries), 1)
192
+ _, entry = entries[0]
193
+ # The queued payload carries the deterministic content-derived id.
194
+ self.assertTrue(entry["document_id"].startswith(f"{session}-r"))
195
+ self.assertIn(f"{session}-u", entry["document_id"]) # full uuids, not 8-char
196
+
197
+ # Drain against a healthy daemon delivers it and deletes the entry.
198
+ self.daemon.fail = False
199
+ from drain_pending import drain
200
+ drain(self._config())
201
+ self.assertEqual(len(self._pending_entries()), 0)
202
+ self.assertIn(entry["document_id"], self.daemon.docs)
203
+
204
+ # -- Test 3 (rewritten): live Stop + reconcile of same window ⇒ ONE doc -----
205
+ def test_live_stop_and_reconcile_converge_on_single_document(self):
206
+ session = "sessC"
207
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
208
+ _write_transcript(tpath, 3, session_prefix=session) # window(3) == whole transcript
209
+ hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
210
+
211
+ # Live Stop retain — writes the content-derived id, advances watermark.
212
+ with mock.patch("retain.increment_turn_count", return_value=3), \
213
+ mock.patch("sys.stdin", _stdin(hook)):
214
+ retain.main()
215
+ self.assertEqual(len(self.daemon.docs), 1)
216
+ live_id = self.daemon.posts[0][0]
217
+ self.assertTrue(live_id.startswith(f"{session}-r"))
218
+
219
+ # Simulate a watermark loss (container recreate) so reconcile RE-posts
220
+ # the same window — proving the two paths collide on document_id.
221
+ os.remove(os.path.join(os.environ["HINDSIGHT_RETAINED_DIR"], f"{session}.json"))
222
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
223
+
224
+ # Two POSTs, identical id, still exactly ONE stored document (upsert).
225
+ self.assertEqual(len(self.daemon.docs), 1)
226
+ self.assertEqual(self.daemon.posts[-1][0], live_id)
227
+
228
+ # -- Test 7: commit-before-ack; 200-not-persisted doesn't advance watermark -
229
+ def test_durability_posts_are_synchronous_and_gate_the_watermark(self):
230
+ session = "sessD"
231
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
232
+ _write_transcript(tpath, 3, session_prefix=session)
233
+ hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
234
+
235
+ # A failing durability POST must NOT advance the watermark ...
236
+ self.daemon.fail = True
237
+ with mock.patch("retain.increment_turn_count", return_value=3), \
238
+ mock.patch("sys.stdin", _stdin(hook)):
239
+ retain.main()
240
+ self.assertIsNone(watermark.load(session))
241
+
242
+ # ... and every durability POST is async_processing=False (commit-before
243
+ # -ack), so a bare async-200 can never falsely mark work committed.
244
+ self.assertTrue(self.daemon.posts, "expected at least one durability POST")
245
+ self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
246
+
247
+ # Next boot re-catches the turns and advances the watermark.
248
+ self.daemon.fail = False
249
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
250
+ self.assertIsNotNone(watermark.load(session))
251
+ self.assertIn("user turn 2", self.daemon.content_blob())
252
+
253
+ # -- Test 10: a bound (200-turn cap) ENQUEUES the remainder, no silent loss -
254
+ def test_turn_cap_enqueues_remainder_never_truncates(self):
255
+ # Regression for F1/F2 (reviewer reproduced silent loss): the older
256
+ # remainder is delivered by the drain, which the daemon may 200-then-drop
257
+ # on an async post; if the watermark had advanced past the remainder,
258
+ # that drop would be permanent loss with no reconcile backstop. This test
259
+ # sets drop_async=True on the drain path — it must still land durably
260
+ # (drain now posts commit-before-ack) AND the watermark must NOT have
261
+ # jumped past the un-confirmed remainder after the capped reconcile.
262
+ session = "sessE"
263
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
264
+ _write_transcript(tpath, 6, session_prefix=session)
265
+ hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
266
+
267
+ # Cap the boot reconcile at 2 human turns; the other 4 must be deferred,
268
+ # not dropped.
269
+ with mock.patch.dict(os.environ, {"HINDSIGHT_RECONCILE_MAX_TURNS": "2"}):
270
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
271
+
272
+ # Inline landed the most-recent 2 turns ...
273
+ blob = self.daemon.content_blob()
274
+ self.assertIn("user turn 5", blob)
275
+ self.assertIn("user turn 4", blob)
276
+ # ... the older remainder is ENQUEUED (not lost) ...
277
+ self.assertEqual(len(self._pending_entries()), 1)
278
+ # ... and the watermark did NOT advance past the un-confirmed remainder
279
+ # (F2): it must not sit at the transcript end. With no prior watermark
280
+ # and a split, no watermark is written at all.
281
+ wm = watermark.load(session)
282
+ self.assertTrue(wm is None or wm["last_uuid"] != f"{session}-a5")
283
+
284
+ # An adversarial daemon that DROPS async extractions (the #3244 hazard):
285
+ # the drain must still persist the remainder durably (commit-before-ack).
286
+ self.daemon.drop_async = True
287
+ from drain_pending import drain
288
+ drain(self._config())
289
+ blob2 = self.daemon.content_blob()
290
+ for i in range(6):
291
+ self.assertIn(f"user turn {i}", blob2)
292
+
293
+ # -- Test 9 (seam): build_retain_payload is network- and mission-write-free -
294
+ def test_build_retain_payload_is_write_free(self):
295
+ session = "sessF"
296
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
297
+ _write_transcript(tpath, 2, session_prefix=session)
298
+ msgs = retain.read_transcript(tpath)
299
+
300
+ def _boom(*a, **kw):
301
+ raise AssertionError("build_retain_payload must not touch the network")
302
+
303
+ with mock.patch.object(HindsightClient, "retain", _boom), \
304
+ mock.patch.object(HindsightClient, "set_bank_mission", _boom):
305
+ built = retain.build_retain_payload(
306
+ self._config(), session, msgs, msgs,
307
+ bank_id="test-bank", api_url="http://fake", api_token=None,
308
+ )
309
+ self.assertIsNotNone(built)
310
+ self.assertTrue(built["document_id"].startswith(f"{session}-r"))
311
+ self.assertEqual(built["last_uuid"], f"{session}-a1")
312
+ # No POST reached the daemon.
313
+ self.assertEqual(self.daemon.posts, [])
314
+
315
+
316
+ class TestWatermark(DurabilityTestBase):
317
+ # -- Test 6: monotonicity + uuid-not-found branch --------------------------
318
+ def test_watermark_never_regresses_and_handles_stale_anchor(self):
319
+ session = "wm1"
320
+ ordered = ["e0", "e1", "e2", "e3"]
321
+ watermark.commit(session, "e2", "doc-e2", ordered_uuids=ordered)
322
+ self.assertEqual(watermark.load(session)["last_uuid"], "e2")
323
+
324
+ # Backward move refused (e1 is before e2).
325
+ watermark.commit(session, "e1", "doc-e1", ordered_uuids=ordered)
326
+ self.assertEqual(watermark.load(session)["last_uuid"], "e2")
327
+
328
+ # Forward move accepted.
329
+ watermark.commit(session, "e3", "doc-e3", ordered_uuids=ordered)
330
+ self.assertEqual(watermark.load(session)["last_uuid"], "e3")
331
+
332
+ # uuid-not-found branch: the stored anchor was compacted away — accept
333
+ # the incoming commit rather than crash/mis-compare.
334
+ compacted_order = ["c0", "c1"]
335
+ got = watermark.commit(session, "c1", "doc-c1", ordered_uuids=compacted_order)
336
+ self.assertIsNotNone(got)
337
+ self.assertEqual(watermark.load(session)["last_uuid"], "c1")
338
+
339
+ def test_watermark_ignores_empty_uuid(self):
340
+ self.assertIsNone(watermark.commit("wm2", "", "doc", ordered_uuids=[]))
341
+ self.assertIsNone(watermark.load("wm2"))
342
+
343
+
344
+ def _stdin(obj):
345
+ import io
346
+ return io.StringIO(json.dumps(obj))
347
+
348
+
349
+ if __name__ == "__main__":
350
+ unittest.main()
@@ -801,7 +801,13 @@ class TestRetainHook:
801
801
  # Should not raise
802
802
  _run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
803
803
 
804
- def test_retain_posts_async_true(self, monkeypatch, tmp_path):
804
+ def test_retain_durability_post_is_commit_before_ack(self, monkeypatch, tmp_path):
805
+ # Switchroom #3244 §1.1: the Stop-hook durability retain now POSTs with
806
+ # ``async=false`` (commit-before-ack) so the 200 proves durable
807
+ # persistence before the watermark advances — a bare async-200
808
+ # (ack-of-receipt) must never mark unpersisted work committed. This
809
+ # replaces the former ``async: true`` assertion (the deliberate
810
+ # behaviour change; other, non-watermark callers still default async).
805
811
  messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
806
812
  transcript = make_transcript_file(tmp_path, messages)
807
813
  hook_input = make_hook_input(transcript_path=transcript)
@@ -815,7 +821,7 @@ class TestRetainHook:
815
821
  _run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
816
822
 
817
823
  if "body" in captured:
818
- assert captured["body"].get("async") is True
824
+ assert captured["body"].get("async") is False
819
825
 
820
826
  def test_retain_includes_context_label(self, monkeypatch, tmp_path):
821
827
  messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]