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,362 @@
1
+ """Switchroom #3244 Part 2 — backfill outcome tests.
2
+
3
+ Stdlib-only (`python3 -m unittest discover tests/`). Every test drives the real
4
+ ``backfill_transcripts`` code against a FAKE in-process daemon (no network, no
5
+ LLM) and asserts OUTCOMES — content landed in the bank / dedup to one document /
6
+ zero writes in dry-run / pacing spacing observed via a fake clock / resume
7
+ skips committed sessions — not merely that a code path ran.
8
+
9
+ Covers design-20260715.md §4 tests 4 (backfill dedup + pacing), 5
10
+ (resumability), 8 (dedups against pre-existing docs + total-loss posted in
11
+ full), 9 (dry-run ZERO writes incl. no mission PATCH).
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import shutil
17
+ import sys
18
+ import tempfile
19
+ import time
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 backfill_transcripts as bf # noqa: E402
28
+ from lib import watermark # noqa: E402
29
+ from lib.client import HindsightClient # noqa: E402
30
+
31
+
32
+ class FakeDaemon:
33
+ """Records retained documents by document_id with UPSERT semantics (the
34
+ confirmed daemon contract: same document_id ⇒ one row, full-replace)."""
35
+
36
+ def __init__(self):
37
+ self.docs = {} # document_id -> {content, ...}
38
+ self.posts = [] # [(document_id, async_processing)]
39
+ self.mission_patches = [] # set_bank_mission calls (must stay empty)
40
+ self.fail = False
41
+ self.max_inflight_seen = 0
42
+ self._inflight = 0
43
+
44
+ def retain(self, bank_id, content, document_id="conversation", context=None,
45
+ metadata=None, tags=None, timeout=15, async_processing=True):
46
+ self._inflight += 1
47
+ self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
48
+ try:
49
+ self.posts.append((document_id, async_processing))
50
+ if self.fail:
51
+ raise RuntimeError("simulated daemon failure")
52
+ self.docs[document_id] = {"content": content, "metadata": metadata}
53
+ return {"ok": True}
54
+ finally:
55
+ self._inflight -= 1
56
+
57
+ def set_bank_mission(self, *a, **kw):
58
+ self.mission_patches.append((a, kw))
59
+ return {"ok": True}
60
+
61
+ def content_blob(self):
62
+ return "\n".join(d["content"] for d in self.docs.values())
63
+
64
+
65
+ def _write_transcript(path, n_turns, session_prefix="u"):
66
+ lines = []
67
+ for i in range(n_turns):
68
+ lines.append(json.dumps({"role": "user", "content": f"user turn {i}", "uuid": f"{session_prefix}-u{i}"}))
69
+ lines.append(json.dumps({"role": "assistant", "content": f"assistant turn {i}", "uuid": f"{session_prefix}-a{i}"}))
70
+ os.makedirs(os.path.dirname(path), exist_ok=True)
71
+ with open(path, "w", encoding="utf-8") as f:
72
+ f.write("\n".join(lines))
73
+
74
+
75
+ class BackfillTestBase(unittest.TestCase):
76
+ def setUp(self):
77
+ self.tmp = tempfile.mkdtemp(prefix="hs-3244-bf-")
78
+ self.plugin_root = os.path.join(self.tmp, "plugin_root")
79
+ self.home = os.path.join(self.tmp, "home")
80
+ self.data = os.path.join(self.tmp, "data")
81
+ self.agents_root = os.path.join(self.tmp, "agents")
82
+ for d in (self.plugin_root, self.home, self.data, self.agents_root):
83
+ os.makedirs(d)
84
+
85
+ settings = {
86
+ "autoRetain": True,
87
+ "autoRecall": True,
88
+ "retainMode": "chunked",
89
+ "retainEveryNTurns": 3,
90
+ "retainOverlapTurns": 0,
91
+ "bankId": "test-bank",
92
+ "reconcileOnStart": True,
93
+ }
94
+ with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
95
+ json.dump(settings, f)
96
+
97
+ self.env = mock.patch.dict(os.environ, {
98
+ "CLAUDE_PLUGIN_ROOT": self.plugin_root,
99
+ "CLAUDE_PLUGIN_DATA": self.data,
100
+ "HOME": self.home,
101
+ "HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
102
+ "HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
103
+ "HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
104
+ "HINDSIGHT_BACKFILL_PROGRESS": os.path.join(self.home, ".hindsight", "backfill-progress.json"),
105
+ "HINDSIGHT_BACKFILL_LOCK": os.path.join(self.home, ".hindsight", "backfill.lock"),
106
+ "HINDSIGHT_AGENTS_DIR": self.agents_root,
107
+ "HINDSIGHT_BACKFILL_DELAY_MS": "0", # tests inject their own delay
108
+ "HINDSIGHT_BACKFILL_MIN_IDLE_S": "0", # freshly-written fixtures aren't "active"
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", "HINDSIGHT_INFLIGHT_LOCK",
114
+ "HINDSIGHT_BACKFILL_PROGRESS", "HINDSIGHT_BACKFILL_LOCK", "HINDSIGHT_AGENTS_DIR",
115
+ "HINDSIGHT_BACKFILL_DELAY_MS", "HINDSIGHT_BACKFILL_MIN_IDLE_S",
116
+ ):
117
+ os.environ.pop(k, None)
118
+
119
+ self.daemon = FakeDaemon()
120
+ self.sleeps = []
121
+ self._patches = [
122
+ mock.patch.object(HindsightClient, "retain", self._fake_retain),
123
+ mock.patch.object(HindsightClient, "set_bank_mission", self._fake_mission),
124
+ mock.patch("backfill_transcripts.get_api_url", return_value="http://fake"),
125
+ mock.patch("backfill_transcripts._SLEEP", self._fake_sleep),
126
+ ]
127
+ for p in self._patches:
128
+ p.start()
129
+
130
+ def _fake_retain(self, *a, **kw):
131
+ return self.daemon.retain(*a, **kw)
132
+
133
+ def _fake_mission(self, *a, **kw):
134
+ return self.daemon.set_bank_mission(*a, **kw)
135
+
136
+ def _fake_sleep(self, secs):
137
+ self.sleeps.append(secs)
138
+
139
+ def tearDown(self):
140
+ for p in self._patches:
141
+ p.stop()
142
+ self.env.stop()
143
+ shutil.rmtree(self.tmp, ignore_errors=True)
144
+
145
+ def _config(self):
146
+ from lib.config import load_config
147
+ return load_config()
148
+
149
+ def _transcript(self, agent, session, n_turns):
150
+ path = os.path.join(self.agents_root, agent, ".claude", "projects", "proj", f"{session}.jsonl")
151
+ _write_transcript(path, n_turns, session_prefix=session)
152
+ return path
153
+
154
+
155
+ class TestBackfill(BackfillTestBase):
156
+ # -- Recovers a total-loss session --------------------------------------
157
+ def test_recovers_total_loss_session(self):
158
+ self._transcript("clerk", "sess-lost", 4)
159
+ report = bf.Backfill(self._config(), commit=True, delay_ms=0).run()
160
+
161
+ blob = self.daemon.content_blob()
162
+ for i in range(4):
163
+ self.assertIn(f"user turn {i}", blob)
164
+ self.assertEqual(report["rollup"]["sessions_total_loss"], 1)
165
+ self.assertGreater(report["rollup"]["posts_ok"], 0)
166
+ # Every durability POST is commit-before-ack.
167
+ self.assertTrue(self.daemon.posts)
168
+ self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
169
+
170
+ # -- Dedups against a pre-existing document with the same deterministic id
171
+ def test_dedups_against_preexisting_document_id(self):
172
+ path = self._transcript("clerk", "sess-dup", 3)
173
+ # Pre-seed the daemon with the EXACT deterministic ids the backfill will
174
+ # derive (as a prior live/backfill run would have written them).
175
+ from retain import read_transcript
176
+ msgs = read_transcript(path)
177
+ slices = bf.chunk_by_human_turns(msgs, bf._slice_turns())
178
+ seeded_ids = []
179
+ for sl in slices:
180
+ built = bf.build_retain_payload(
181
+ self._config(), "sess-dup", sl, msgs, bank_id="test-bank",
182
+ api_url="http://fake", api_token=None,
183
+ )
184
+ self.daemon.docs[built["document_id"]] = {"content": "STALE", "metadata": {}}
185
+ seeded_ids.append(built["document_id"])
186
+ self.assertTrue(seeded_ids)
187
+ before = len(self.daemon.docs)
188
+
189
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
190
+
191
+ # UPSERT: re-posting the same ids overwrites; document count unchanged
192
+ # (no duplicates), and content now reflects the recovered turns.
193
+ self.assertEqual(len(self.daemon.docs), before)
194
+ for did in seeded_ids:
195
+ self.assertNotEqual(self.daemon.docs[did]["content"], "STALE")
196
+
197
+ # -- Dry-run performs ZERO writes (no retain POST, no mission PATCH) -----
198
+ def test_dry_run_is_zero_write(self):
199
+ self._transcript("clerk", "sess-dry", 5)
200
+ report = bf.Backfill(self._config(), commit=False, delay_ms=0).run()
201
+
202
+ # Report still lists the gap it WOULD recover ...
203
+ self.assertEqual(report["rollup"]["sessions_total_loss"], 1)
204
+ self.assertGreater(report["rollup"]["turns_recovered"], 0)
205
+ # ... but not a single write reached the daemon.
206
+ self.assertEqual(self.daemon.posts, [])
207
+ self.assertEqual(self.daemon.mission_patches, [])
208
+ # No progress recorded, no watermark advanced.
209
+ self.assertFalse(os.path.exists(os.environ["HINDSIGHT_BACKFILL_PROGRESS"]))
210
+ self.assertIsNone(watermark.load("sess-dry"))
211
+
212
+ # -- Pacing: serial (one in flight) and spaced by the configured delay ---
213
+ def test_pacing_is_serial_and_spaced(self):
214
+ # Two sessions, several slices each (slice_turns=1 → one POST per turn).
215
+ self._transcript("clerk", "sess-p1", 3)
216
+ self._transcript("clerk", "sess-p2", 2)
217
+ bf.Backfill(self._config(), commit=True, delay_ms=1500, slice_turns=1).run()
218
+
219
+ # 5 POSTs total; never more than one in flight (serial worker).
220
+ self.assertEqual(len(self.daemon.posts), 5)
221
+ self.assertEqual(self.daemon.max_inflight_seen, 1)
222
+ # Each POST after the first is preceded by the configured 1.5s delay.
223
+ spaced = [s for s in self.sleeps if abs(s - 1.5) < 1e-9]
224
+ self.assertEqual(len(spaced), 4) # 5 posts → 4 inter-post gaps
225
+
226
+ # -- Resumability: interrupt after N sessions, re-run skips committed -----
227
+ def test_resumes_and_skips_committed_sessions(self):
228
+ for i in range(4):
229
+ self._transcript("clerk", f"sess-r{i}", 2)
230
+
231
+ # First run: raise after the 2nd session's POST completes.
232
+ real_post = bf.Backfill._post_slice
233
+ state = {"posts": 0}
234
+
235
+ def _interrupting_post(self, bank_id, built):
236
+ # Raise at the START of the 3rd POST so sessions 1 and 2 are fully
237
+ # committed AND recorded done before the interruption.
238
+ state["posts"] += 1
239
+ if state["posts"] > 2:
240
+ raise KeyboardInterrupt("simulated interruption after 2 sessions")
241
+ return real_post(self, bank_id, built)
242
+
243
+ with mock.patch.object(bf.Backfill, "_post_slice", _interrupting_post):
244
+ with self.assertRaises(KeyboardInterrupt):
245
+ bf.Backfill(self._config(), commit=True, delay_ms=0, slice_turns=40).run()
246
+
247
+ # Two sessions were committed + recorded done before the interruption.
248
+ prog = bf.Progress(os.environ["HINDSIGHT_BACKFILL_PROGRESS"])
249
+ done_first = [s for s in ("sess-r0", "sess-r1", "sess-r2", "sess-r3")
250
+ if prog.is_done("clerk", s)]
251
+ self.assertEqual(len(done_first), 2)
252
+ posts_after_first = len(self.daemon.posts)
253
+
254
+ # Re-run to completion: the already-done sessions must be SKIPPED (no
255
+ # re-POST), the remaining ones completed, zero duplicates.
256
+ report = bf.Backfill(self._config(), commit=True, delay_ms=0, slice_turns=40).run()
257
+ self.assertEqual(report["rollup"]["sessions_already_done"], 2)
258
+
259
+ prog2 = bf.Progress(os.environ["HINDSIGHT_BACKFILL_PROGRESS"])
260
+ for s in ("sess-r0", "sess-r1", "sess-r2", "sess-r3"):
261
+ self.assertTrue(prog2.is_done("clerk", s))
262
+ # Only the two remaining sessions were posted on the re-run.
263
+ self.assertEqual(len(self.daemon.posts) - posts_after_first, 2)
264
+ # All four sessions' content landed, one document per session (no dupes).
265
+ self.assertEqual(len(self.daemon.docs), 4)
266
+
267
+ # -- Partial (live-watermarked) sessions are skipped for review ----------
268
+ def test_partial_session_skipped_for_review(self):
269
+ path = self._transcript("clerk", "sess-partial", 3)
270
+ # A live watermark means the live path already retained part of it.
271
+ watermark.commit("sess-partial", "sess-partial-a1", "sess-partial-r...",
272
+ transcript_path=path, ordered_uuids=["sess-partial-u0"])
273
+
274
+ report = bf.Backfill(self._config(), commit=True, delay_ms=0).run()
275
+
276
+ self.assertEqual(report["rollup"]["sessions_partial_skipped"], 1)
277
+ self.assertEqual(report["rollup"]["sessions_total_loss"], 0)
278
+ # Conservative: nothing posted for the partial session.
279
+ self.assertEqual(self.daemon.posts, [])
280
+
281
+ # -- --agent restricts scope --------------------------------------------
282
+ def test_agent_filter_restricts_scope(self):
283
+ self._transcript("clerk", "sess-a", 2)
284
+ self._transcript("scout", "sess-b", 2)
285
+ report = bf.Backfill(self._config(), commit=True, delay_ms=0).run(agent_filter={"clerk"})
286
+ self.assertIn("clerk", report["agents"])
287
+ self.assertNotIn("scout", report["agents"])
288
+
289
+ # -- F3: the total-loss GATE is load-bearing (live doc, no watermark) -----
290
+ def test_total_loss_gate_prevents_live_vs_backfill_duplication(self):
291
+ # A session the LIVE path retained (a sliding-window doc in the bank) but
292
+ # whose watermark IS present. Backfill must NOT re-post it — the ids do
293
+ # NOT converge with the live path's, so a re-post would DUPLICATE the
294
+ # turns under different ids the daemon can't upsert away. The guard here
295
+ # is the classification gate, not id-convergence.
296
+ path = self._transcript("clerk", "sess-live", 5)
297
+ # Seed a live-path doc under a DIFFERENT (sliding-window-shaped) id.
298
+ live_id = "sess-live-r-live-sliding-window"
299
+ self.daemon.docs[live_id] = {"content": "live user turn 3\nlive user turn 4", "metadata": {}}
300
+ # The live path also wrote a watermark (the normal, healthy case).
301
+ watermark.commit("sess-live", "sess-live-a4", live_id,
302
+ transcript_path=path, ordered_uuids=["sess-live-u4"])
303
+ before = len(self.daemon.docs)
304
+
305
+ report = bf.Backfill(self._config(), commit=True, delay_ms=0).run()
306
+
307
+ # Gate held: classified partial (watermark present) → skipped, zero posts,
308
+ # no second doc for the same turns.
309
+ self.assertEqual(report["rollup"]["sessions_partial_skipped"], 1)
310
+ self.assertEqual(self.daemon.posts, [])
311
+ self.assertEqual(len(self.daemon.docs), before)
312
+
313
+ # Prove the gate is load-bearing: with the watermark REMOVED (the F3
314
+ # watermark-write-failure shape) the same session WOULD be reclassified
315
+ # total-loss and re-posted, creating additional docs beyond the live one.
316
+ os.remove(os.path.join(os.environ["HINDSIGHT_RETAINED_DIR"], "sess-live.json"))
317
+ bf.Backfill(self._config(), commit=True, delay_ms=0,
318
+ progress=bf.Progress(os.path.join(self.home, ".hindsight", "prog2.json"))).run()
319
+ self.assertGreater(len(self.daemon.docs), before) # gate removed ⇒ duplication
320
+
321
+ # -- F1: a dynamic-bank agent is refused, never written to a guessed bank -
322
+ def test_dynamic_bank_agent_refused(self):
323
+ self._transcript("clerk", "sess-dyn", 3)
324
+ cfg = self._config()
325
+ cfg["dynamicBankId"] = True
326
+ cfg["dynamicBankGranularity"] = ["agent", "project"]
327
+
328
+ report = bf.Backfill(cfg, commit=True, delay_ms=0).run(agent_filter={"clerk"})
329
+
330
+ self.assertTrue(report["agents"]["clerk"]["dynamic_bank_skipped"])
331
+ self.assertEqual(report["rollup"]["dynamic_bank_agents_skipped"], 1)
332
+ # Nothing written to any guessed bank.
333
+ self.assertEqual(self.daemon.posts, [])
334
+ self.assertEqual(report["rollup"]["posts_ok"], 0)
335
+
336
+ # -- F2: resuming with a different --slice-turns is refused --------------
337
+ def test_slice_turns_mismatch_refused_on_resume(self):
338
+ self._transcript("clerk", "sess-st", 3)
339
+ # First commit run pins slice_turns=40.
340
+ bf.Backfill(self._config(), commit=True, delay_ms=0, slice_turns=40).run()
341
+ # A resume with a different slice size must refuse (would orphan run-1's
342
+ # slices as duplicates under new ids).
343
+ with self.assertRaises(ValueError):
344
+ bf.Backfill(self._config(), commit=True, delay_ms=0, slice_turns=10).run()
345
+
346
+ # -- F3: an active / recently-written session is skipped -----------------
347
+ def test_active_session_skipped(self):
348
+ path = self._transcript("clerk", "sess-hot", 3)
349
+ # Fresh mtime (now) with a 1h idle threshold → classified active.
350
+ report = bf.Backfill(self._config(), commit=True, delay_ms=0, min_idle_s=3600).run()
351
+ self.assertEqual(report["rollup"]["sessions_active_skipped"], 1)
352
+ self.assertEqual(report["rollup"]["sessions_total_loss"], 0)
353
+ self.assertEqual(self.daemon.posts, [])
354
+ # Backdate the transcript → now idle → recovered.
355
+ old = time.time() - 7200
356
+ os.utime(path, (old, old))
357
+ report2 = bf.Backfill(self._config(), commit=True, delay_ms=0, min_idle_s=3600).run()
358
+ self.assertEqual(report2["rollup"]["sessions_total_loss"], 1)
359
+
360
+
361
+ if __name__ == "__main__":
362
+ unittest.main()