switchroom 0.18.28 → 0.18.30

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 (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -0,0 +1,467 @@
1
+ """switchroom #3244 follow-up — log-driven recovery (``--from-logs``) tests.
2
+
3
+ Stdlib-only (``python3 -m unittest discover tests/``). Every test drives the
4
+ real ``backfill_transcripts`` ``--from-logs`` path against a FAKE in-process
5
+ daemon + FAKE per-agent registry.db + FAKE transcripts (no network, no LLM, no
6
+ real bank) and asserts OUTCOMES.
7
+
8
+ Covers the three design red-team must-fixes:
9
+ 1. BROAD candidate classifier — a genuinely-missing ``restart`` session is
10
+ restored (would be dropped by the dead in-flight predicate).
11
+ 2. SESSION-LEVEL, scheme-agnostic membership — restore fires ONLY for a
12
+ session with ZERO documents in the bank; a session with ANY document
13
+ present is treated as ``present`` and skipped. Partial-within-a-populated-
14
+ session recovery is out of scope (a legacy epoch-ms id can't be mapped to
15
+ the turns it covers), so membership is decided at the session, not slice,
16
+ level (BLOCKER-1 fix superseded the earlier slice-level design).
17
+ 3. Event-span CONTAINMENT join — picks the right transcript where a naive
18
+ mtime-window would miss; ambiguous → refused, not guessed.
19
+ Plus: fail-closed on membership error, wrong/refused bank → zero writes,
20
+ dry-run → zero writes, pacing serialised via the fake clock.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ import sqlite3
27
+ import sys
28
+ import tempfile
29
+ import time
30
+ import unittest
31
+ from unittest import mock
32
+
33
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
34
+ if SCRIPTS_DIR not in sys.path:
35
+ sys.path.insert(0, SCRIPTS_DIR)
36
+
37
+ import backfill_transcripts as bf # noqa: E402
38
+ from lib import watermark # noqa: E402
39
+ from lib.client import HindsightClient # noqa: E402
40
+
41
+ _REGISTRY_COLS = [
42
+ "turn_key", "chat_id", "thread_id", "started_at", "ended_at", "ended_via",
43
+ "last_assistant_msg_id", "last_assistant_done", "last_user_msg_id",
44
+ "user_prompt_preview", "assistant_reply_preview", "tool_call_count",
45
+ "created_at", "updated_at", "interrupt_reason", "resumed_at",
46
+ ]
47
+
48
+
49
+ class FakeDaemon:
50
+ """Upsert-by-document_id store + a session-substring document listing that
51
+ mirrors the daemon's server-side ``q=`` filter."""
52
+
53
+ def __init__(self):
54
+ self.docs = {}
55
+ self.posts = []
56
+ self.max_inflight_seen = 0
57
+ self._inflight = 0
58
+ self.membership_error = False
59
+
60
+ def retain(self, bank_id, content, document_id="conversation", context=None,
61
+ metadata=None, tags=None, timeout=15, async_processing=True):
62
+ self._inflight += 1
63
+ self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
64
+ try:
65
+ self.posts.append((document_id, async_processing))
66
+ self.docs[document_id] = {"content": content, "bank_id": bank_id}
67
+ return {"ok": True}
68
+ finally:
69
+ self._inflight -= 1
70
+
71
+ def list_session_document_ids(self, bank_id, session_id, page=200,
72
+ max_pages=50, timeout=10):
73
+ if self.membership_error:
74
+ raise RuntimeError("simulated daemon membership failure")
75
+ return {did for did, d in self.docs.items()
76
+ if d.get("bank_id") == bank_id and session_id in did}
77
+
78
+
79
+ def _write_registry(path, rows, with_session_id=False):
80
+ os.makedirs(os.path.dirname(path), exist_ok=True)
81
+ cols = list(_REGISTRY_COLS)
82
+ if with_session_id:
83
+ cols.append("session_id")
84
+ conn = sqlite3.connect(path)
85
+ try:
86
+ conn.execute(f"CREATE TABLE turns ({', '.join(c + ' TEXT' for c in cols)})")
87
+ for row in rows:
88
+ vals = [row.get(c) for c in cols]
89
+ conn.execute(
90
+ f"INSERT INTO turns ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})",
91
+ vals,
92
+ )
93
+ conn.commit()
94
+ finally:
95
+ conn.close()
96
+
97
+
98
+ def _write_transcript_ts(path, n_turns, session_prefix, base_ms, step_ms=1000, chat_id="123"):
99
+ """Flat-format JSONL with per-line ``timestamp`` (ms) + ``chat_id`` so the
100
+ containment join and read_transcript both work."""
101
+ os.makedirs(os.path.dirname(path), exist_ok=True)
102
+ lines = []
103
+ ts = base_ms
104
+ for i in range(n_turns):
105
+ lines.append(json.dumps({
106
+ "role": "user", "content": f"user turn {i}",
107
+ "uuid": f"{session_prefix}-u{i}", "timestamp": ts, "chat_id": chat_id,
108
+ }))
109
+ ts += step_ms
110
+ lines.append(json.dumps({
111
+ "role": "assistant", "content": f"assistant turn {i}",
112
+ "uuid": f"{session_prefix}-a{i}", "timestamp": ts, "chat_id": chat_id,
113
+ }))
114
+ ts += step_ms
115
+ with open(path, "w", encoding="utf-8") as f:
116
+ f.write("\n".join(lines))
117
+ return base_ms, ts # (first_ms, last_ms)
118
+
119
+
120
+ class FromLogsTestBase(unittest.TestCase):
121
+ def setUp(self):
122
+ self.tmp = tempfile.mkdtemp(prefix="hs-3244-fl-")
123
+ self.plugin_root = os.path.join(self.tmp, "plugin_root")
124
+ self.home = os.path.join(self.tmp, "home")
125
+ self.data = os.path.join(self.tmp, "data")
126
+ self.agents_root = os.path.join(self.tmp, "state", "agents")
127
+ for d in (self.plugin_root, self.home, self.data, self.agents_root):
128
+ os.makedirs(d)
129
+ self.switchroom_yaml = os.path.join(self.tmp, "state", "switchroom.yaml")
130
+
131
+ settings = {
132
+ "autoRetain": True, "retainMode": "chunked", "retainEveryNTurns": 3,
133
+ "retainOverlapTurns": 0, "bankId": "unused-in-from-logs",
134
+ }
135
+ with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
136
+ json.dump(settings, f)
137
+
138
+ self.env = mock.patch.dict(os.environ, {
139
+ "CLAUDE_PLUGIN_ROOT": self.plugin_root,
140
+ "CLAUDE_PLUGIN_DATA": self.data,
141
+ "HOME": self.home,
142
+ "HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
143
+ "HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
144
+ "HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
145
+ "HINDSIGHT_BACKFILL_PROGRESS": os.path.join(self.home, ".hindsight", "backfill-progress.json"),
146
+ "HINDSIGHT_BACKFILL_LOCK": os.path.join(self.home, ".hindsight", "backfill.lock"),
147
+ "HINDSIGHT_AGENTS_DIR": self.agents_root,
148
+ "HINDSIGHT_BACKFILL_DELAY_MS": "0",
149
+ "HINDSIGHT_BACKFILL_MIN_IDLE_S": "0",
150
+ }, clear=False)
151
+ self.env.start()
152
+ for k in list(os.environ):
153
+ if k.startswith("HINDSIGHT_") and k not in (
154
+ "HINDSIGHT_PENDING_DIR", "HINDSIGHT_RETAINED_DIR", "HINDSIGHT_INFLIGHT_LOCK",
155
+ "HINDSIGHT_BACKFILL_PROGRESS", "HINDSIGHT_BACKFILL_LOCK", "HINDSIGHT_AGENTS_DIR",
156
+ "HINDSIGHT_BACKFILL_DELAY_MS", "HINDSIGHT_BACKFILL_MIN_IDLE_S",
157
+ ):
158
+ os.environ.pop(k, None)
159
+
160
+ self.daemon = FakeDaemon()
161
+ self.sleeps = []
162
+ self._patches = [
163
+ mock.patch.object(HindsightClient, "retain", self._fake_retain),
164
+ mock.patch.object(HindsightClient, "list_session_document_ids", self._fake_membership),
165
+ mock.patch("backfill_transcripts.get_api_url", return_value="http://fake"),
166
+ mock.patch("backfill_transcripts._SLEEP", self._fake_sleep),
167
+ ]
168
+ for p in self._patches:
169
+ p.start()
170
+
171
+ def _fake_retain(self, *a, **kw):
172
+ return self.daemon.retain(*a, **kw)
173
+
174
+ def _fake_membership(self, *a, **kw):
175
+ return self.daemon.list_session_document_ids(*a, **kw)
176
+
177
+ def _fake_sleep(self, secs):
178
+ self.sleeps.append(secs)
179
+
180
+ def tearDown(self):
181
+ for p in self._patches:
182
+ p.stop()
183
+ self.env.stop()
184
+ shutil.rmtree(self.tmp, ignore_errors=True)
185
+
186
+ def _config(self):
187
+ from lib.config import load_config
188
+ return load_config()
189
+
190
+ def _write_yaml(self, mapping):
191
+ """mapping: {agent: collection_or_None}."""
192
+ lines = ["agents:"]
193
+ for agent, coll in mapping.items():
194
+ lines.append(f" {agent}:")
195
+ lines.append(" purpose: test agent")
196
+ if coll is not None:
197
+ lines.append(" memory:")
198
+ lines.append(f" collection: {coll}")
199
+ os.makedirs(os.path.dirname(self.switchroom_yaml), exist_ok=True)
200
+ with open(self.switchroom_yaml, "w") as f:
201
+ f.write("\n".join(lines) + "\n")
202
+
203
+ def _write_settings(self, agent, bank_id):
204
+ p = os.path.join(self.agents_root, agent, ".claude", "settings.json")
205
+ os.makedirs(os.path.dirname(p), exist_ok=True)
206
+ with open(p, "w") as f:
207
+ json.dump({"mcpServers": {"hindsight": {"headers": {"X-Bank-Id": bank_id}}}}, f)
208
+
209
+ def _transcript(self, agent, session, n_turns, base_ms, chat_id="123"):
210
+ path = os.path.join(self.agents_root, agent, ".claude", "projects", "proj", f"{session}.jsonl")
211
+ return path, _write_transcript_ts(path, n_turns, session, base_ms, chat_id=chat_id)
212
+
213
+ def _registry(self, agent, rows, with_session_id=False):
214
+ p = os.path.join(self.agents_root, agent, "telegram", "registry.db")
215
+ _write_registry(p, rows, with_session_id=with_session_id)
216
+ return p
217
+
218
+ def _seed_legacy_doc(self, session, bank_id, epoch_ms=1783796975304):
219
+ """Seed a doc under the LEGACY ``{session_id}-{epoch_ms}`` scheme — the
220
+ one production banks actually use (NOT the -r{uuid} scheme the tool
221
+ computes). Session-level membership must see this as PRESENT."""
222
+ did = f"{session}-{epoch_ms}"
223
+ self.daemon.docs[did] = {"content": "LEGACY-PRESENT", "bank_id": bank_id}
224
+ return did
225
+
226
+ def _run(self, agent, commit=True, **kw):
227
+ return bf.Backfill(
228
+ self._config(), commit=commit, delay_ms=kw.pop("delay_ms", 0),
229
+ slice_turns=kw.pop("slice_turns", 1), from_logs=True,
230
+ switchroom_yaml=self.switchroom_yaml, min_idle_s=kw.pop("min_idle_s", 0),
231
+ **kw,
232
+ ).run(agent_filter={agent})
233
+
234
+
235
+ class TestFromLogs(FromLogsTestBase):
236
+ # --- BLOCKER-1: a session with LEGACY epoch-ms docs is PRESENT → zero
237
+ # restore. This FAILS on the pre-fix slice-level exact-id code (which
238
+ # computes -r{uuid} ids, never matches the legacy id, and re-posts every
239
+ # slice → duplicates the intact session). ------------------------------
240
+ def test_legacy_scheme_docs_present_zero_restore(self):
241
+ self._write_yaml({"clerk": None}) # bank = name "clerk"
242
+ self._write_settings("clerk", "clerk")
243
+ base = 1_700_000_000_000
244
+ path, (first, last) = self._transcript("clerk", "sess-legacy", 5, base)
245
+ # The live path retained this session under the LEGACY {session}-{ms}
246
+ # scheme — the only scheme deployed banks hold today.
247
+ legacy_id = self._seed_legacy_doc("sess-legacy", "clerk")
248
+ before = set(self.daemon.docs)
249
+ self._registry("clerk", [{
250
+ "turn_key": "123:_:t1", "chat_id": "123", "started_at": str(first + 500),
251
+ "ended_at": str(last), "ended_via": "sigterm",
252
+ }])
253
+
254
+ report = self._run("clerk")
255
+ ar = report["agents"]["clerk"]
256
+
257
+ # Behavioural gate first: NOT a single POST — the legacy-keyed session
258
+ # is never duplicated. On the pre-fix slice-level code this is 5 posts.
259
+ self.assertEqual(self.daemon.posts, [])
260
+ self.assertEqual(set(self.daemon.docs), before)
261
+ self.assertIn(legacy_id, self.daemon.docs)
262
+ self.assertEqual(ar["sessions_has_documents"], 1)
263
+ self.assertEqual(ar["sessions_total_loss"], 0)
264
+ self.assertEqual(ar["sessions_restored"], 0)
265
+
266
+ # --- must-fix #1: a genuinely-empty (total-loss) `restart` session IS
267
+ # restored (proves broad classifier + total-loss gate). ---------------
268
+ def test_missing_restart_session_restored(self):
269
+ self._write_yaml({"clerk": None})
270
+ self._write_settings("clerk", "clerk")
271
+ base = 1_700_000_000_000
272
+ path, (first, last) = self._transcript("clerk", "sess-restart", 3, base)
273
+ # Empty bank → total loss. ended_via='restart' with NULL in-flight
274
+ # columns (the dead-predicate shape) must still be recovered.
275
+ self._registry("clerk", [{
276
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
277
+ "ended_at": str(last), "ended_via": "restart",
278
+ "last_assistant_done": None, "tool_call_count": None,
279
+ }])
280
+
281
+ report = self._run("clerk")
282
+ ar = report["agents"]["clerk"]
283
+ self.assertEqual(ar["sessions_total_loss"], 1)
284
+ self.assertEqual(ar["sessions_restored"], 1)
285
+ self.assertEqual(ar["slices_restored"], 3)
286
+ self.assertEqual(len(self.daemon.posts), 3)
287
+ self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
288
+
289
+ # --- mixed scheme fleet: legacy-present session skipped, empty session
290
+ # restored — one run, no cross-contamination. --------------------------
291
+ def test_mixed_present_and_total_loss(self):
292
+ self._write_yaml({"clerk": None})
293
+ self._write_settings("clerk", "clerk")
294
+ base = 1_700_000_000_000
295
+ pa, (fa, la) = self._transcript("clerk", "sess-has", 3, base)
296
+ self._seed_legacy_doc("sess-has", "clerk")
297
+ base_b = base + 10_000_000
298
+ pb, (fb, lb) = self._transcript("clerk", "sess-empty", 3, base_b)
299
+ self._registry("clerk", [
300
+ {"turn_key": "123:_:a", "chat_id": "123", "started_at": str(fa + 100),
301
+ "ended_at": str(la), "ended_via": "sigterm"},
302
+ {"turn_key": "123:_:b", "chat_id": "123", "started_at": str(fb + 100),
303
+ "ended_at": str(lb), "ended_via": "restart"},
304
+ ])
305
+
306
+ report = self._run("clerk")
307
+ ar = report["agents"]["clerk"]
308
+ self.assertEqual(ar["sessions_has_documents"], 1)
309
+ self.assertEqual(ar["sessions_restored"], 1)
310
+ # Only the empty session's slices were posted.
311
+ self.assertTrue(all("sess-empty" in did for did, _ in self.daemon.posts))
312
+ self.assertEqual(len(self.daemon.posts), 3)
313
+
314
+ # --- must-fix #3: span containment picks the right transcript -----------
315
+ def test_span_containment_join_selects_correct_transcript(self):
316
+ self._write_yaml({"clerk": None})
317
+ self._write_settings("clerk", "clerk")
318
+ # Two sessions far apart in time. The incident turn's timestamp falls
319
+ # INSIDE session A's span; a naive "nearest mtime" would pick B (written
320
+ # last). Only A must be chosen.
321
+ base_a = 1_700_000_000_000
322
+ path_a, (fa, la) = self._transcript("clerk", "sess-A", 3, base_a)
323
+ base_b = base_a + 10_000_000 # much later
324
+ path_b, (fb, lb) = self._transcript("clerk", "sess-B", 3, base_b)
325
+ # Make B the most-recently-written file (naive mtime target).
326
+ os.utime(path_b, None)
327
+ self._registry("clerk", [{
328
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(fa + 200),
329
+ "ended_at": str(la), "ended_via": "sigterm",
330
+ }])
331
+
332
+ report = self._run("clerk")
333
+ ar = report["agents"]["clerk"]
334
+ restored_sessions = [s["session"] for s in ar["sessions"]
335
+ if s.get("action") == "restored"]
336
+ self.assertEqual(restored_sessions, ["sess-A"])
337
+ # B was never a candidate → untouched.
338
+ self.assertTrue(all("sess-B" not in did for did, _ in self.daemon.posts))
339
+
340
+ # --- must-fix #3: ambiguous span → refused, not guessed -----------------
341
+ def test_ambiguous_join_refused(self):
342
+ self._write_yaml({"clerk": None})
343
+ self._write_settings("clerk", "clerk")
344
+ base = 1_700_000_000_000
345
+ # Two OVERLAPPING transcripts both containing the turn timestamp.
346
+ p1, (f1, l1) = self._transcript("clerk", "sess-ov1", 5, base, chat_id="123")
347
+ p2, (f2, l2) = self._transcript("clerk", "sess-ov2", 5, base, chat_id="123")
348
+ self._registry("clerk", [{
349
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(f1 + 500),
350
+ "ended_at": str(l1), "ended_via": "sigterm",
351
+ }])
352
+
353
+ report = self._run("clerk")
354
+ ar = report["agents"]["clerk"]
355
+ self.assertEqual(ar["ambiguous_turns"], 1)
356
+ self.assertEqual(ar["candidate_sessions"], 0)
357
+ self.assertEqual(self.daemon.posts, [])
358
+
359
+ # --- membership query error → fail-closed → NOT restored ----------------
360
+ def test_membership_error_fails_closed(self):
361
+ self._write_yaml({"clerk": None})
362
+ self._write_settings("clerk", "clerk")
363
+ base = 1_700_000_000_000
364
+ path, (first, last) = self._transcript("clerk", "sess-err", 3, base)
365
+ self._registry("clerk", [{
366
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
367
+ "ended_at": str(last), "ended_via": "timeout",
368
+ }])
369
+ self.daemon.membership_error = True
370
+
371
+ report = self._run("clerk")
372
+ ar = report["agents"]["clerk"]
373
+ self.assertEqual(ar["sessions_membership_error"], 1)
374
+ self.assertEqual(ar["sessions_restored"], 0)
375
+ self.assertEqual(self.daemon.posts, [])
376
+
377
+ # --- wrong/mismatched bank → refused, zero writes -----------------------
378
+ def test_bank_mismatch_refused(self):
379
+ self._write_yaml({"clerk": "clerk"}) # yaml says bank "clerk"
380
+ self._write_settings("clerk", "claude_code") # header disagrees
381
+ base = 1_700_000_000_000
382
+ path, (first, last) = self._transcript("clerk", "sess-x", 3, base)
383
+ self._registry("clerk", [{
384
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
385
+ "ended_at": str(last), "ended_via": "sigterm",
386
+ }])
387
+
388
+ report = self._run("clerk")
389
+ ar = report["agents"]["clerk"]
390
+ self.assertTrue(ar["bank_refused"])
391
+ self.assertEqual(report["rollup"]["banks_refused"], 1)
392
+ self.assertEqual(self.daemon.posts, [])
393
+
394
+ def test_missing_yaml_row_refused(self):
395
+ self._write_yaml({"someone-else": None}) # no clerk row
396
+ base = 1_700_000_000_000
397
+ path, (first, last) = self._transcript("clerk", "sess-x", 3, base)
398
+ self._registry("clerk", [{
399
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
400
+ "ended_at": str(last), "ended_via": "sigterm",
401
+ }])
402
+ report = self._run("clerk")
403
+ self.assertTrue(report["agents"]["clerk"]["bank_refused"])
404
+ self.assertEqual(self.daemon.posts, [])
405
+
406
+ # --- dry-run → zero writes ----------------------------------------------
407
+ def test_dry_run_zero_writes(self):
408
+ self._write_yaml({"clerk": None})
409
+ self._write_settings("clerk", "clerk")
410
+ base = 1_700_000_000_000
411
+ path, (first, last) = self._transcript("clerk", "sess-dry", 4, base)
412
+ self._registry("clerk", [{
413
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
414
+ "ended_at": str(last), "ended_via": "sigterm",
415
+ }])
416
+
417
+ report = self._run("clerk", commit=False)
418
+ ar = report["agents"]["clerk"]
419
+ # Reports the gap it WOULD fill ...
420
+ self.assertEqual(ar["sessions_total_loss"], 1)
421
+ self.assertEqual(ar["slices_restored"], 4)
422
+ self.assertEqual(ar["turns_recovered"], 4)
423
+ # ... but nothing was written.
424
+ self.assertEqual(self.daemon.posts, [])
425
+ self.assertFalse(os.path.exists(os.environ["HINDSIGHT_BACKFILL_PROGRESS"]))
426
+ self.assertIsNone(watermark.load("sess-dry"))
427
+
428
+ # --- pacing: membership GET + restore POSTs serialised + spaced ---------
429
+ def test_pacing_serialised_and_spaced(self):
430
+ self._write_yaml({"clerk": None})
431
+ self._write_settings("clerk", "clerk")
432
+ base = 1_700_000_000_000
433
+ path, (first, last) = self._transcript("clerk", "sess-pace", 3, base)
434
+ self._registry("clerk", [{
435
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
436
+ "ended_at": str(last), "ended_via": "sigterm",
437
+ }])
438
+
439
+ self._run("clerk", delay_ms=1500)
440
+ # 3 restore POSTs, never more than one in flight.
441
+ self.assertEqual(len(self.daemon.posts), 3)
442
+ self.assertEqual(self.daemon.max_inflight_seen, 1)
443
+ # 1 membership GET + 3 POSTs = 4 paced ops → 3 inter-op 1.5s gaps.
444
+ spaced = [s for s in self.sleeps if abs(s - 1.5) < 1e-9]
445
+ self.assertEqual(len(spaced), 3)
446
+
447
+ # --- direct session_id join (migrated registry) -------------------------
448
+ def test_direct_sessionid_join(self):
449
+ self._write_yaml({"clerk": None})
450
+ self._write_settings("clerk", "clerk")
451
+ base = 1_700_000_000_000
452
+ path, (first, last) = self._transcript("clerk", "sess-direct", 3, base)
453
+ # started_at deliberately OUTSIDE the span → only the stamped session_id
454
+ # can join it.
455
+ self._registry("clerk", [{
456
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": "1",
457
+ "ended_at": "2", "ended_via": "restart", "session_id": "sess-direct",
458
+ }], with_session_id=True)
459
+
460
+ report = self._run("clerk")
461
+ ar = report["agents"]["clerk"]
462
+ self.assertEqual(ar["sessions_restored"], 1)
463
+ self.assertEqual(len(self.daemon.posts), 3)
464
+
465
+
466
+ if __name__ == "__main__":
467
+ unittest.main()
@@ -329,6 +329,41 @@ class TestFormatMemories:
329
329
  result = format_memories(mems)
330
330
  assert "bare memory" in result
331
331
 
332
+ # switchroom #tz-fix (recall side): mentioned_at is a UTC ISO timestamp
333
+ # from the Hindsight server; recall must render it in the agent's LOCAL
334
+ # am/pm, not surface a UTC "when".
335
+ def test_mentioned_at_utc_iso_rendered_local(self, monkeypatch):
336
+ monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
337
+ monkeypatch.delenv("TZ", raising=False)
338
+ # 04:09Z in July → AEST (UTC+10) → 02:09 PM local.
339
+ mems = [{"text": "m", "mentioned_at": "2026-07-16T04:09:00Z"}]
340
+ result = format_memories(mems)
341
+ assert "(2026-07-16 02:09 PM AEST)" in result
342
+ # Never the raw UTC value.
343
+ assert "04:09:00Z" not in result
344
+ assert "T04:09" not in result
345
+
346
+ def test_mentioned_at_offset_form_rendered_local(self, monkeypatch):
347
+ monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
348
+ monkeypatch.delenv("TZ", raising=False)
349
+ mems = [{"text": "m", "mentioned_at": "2026-07-16T04:09:00+00:00"}]
350
+ result = format_memories(mems)
351
+ assert "(2026-07-16 02:09 PM AEST)" in result
352
+
353
+ def test_mentioned_at_date_only_preserved_verbatim(self, monkeypatch):
354
+ # A bare date has no wall clock to shift — surface it verbatim rather
355
+ # than fabricating a midnight time.
356
+ monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
357
+ mems = [{"text": "m", "mentioned_at": "2024-01-01"}]
358
+ result = format_memories(mems)
359
+ assert "(2024-01-01)" in result
360
+
361
+ def test_mentioned_at_unparseable_preserved_verbatim(self, monkeypatch):
362
+ monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
363
+ mems = [{"text": "m", "mentioned_at": "not-a-timestampT!!"}]
364
+ result = format_memories(mems)
365
+ assert "(not-a-timestampT!!)" in result
366
+
332
367
 
333
368
  # ---------------------------------------------------------------------------
334
369
  # _is_channel_message_tool
@@ -585,10 +620,31 @@ class TestPrepareRetentionTranscript:
585
620
 
586
621
 
587
622
  class TestFormatCurrentTime:
588
- def test_includes_utc_suffix(self):
589
- # The "UTC" suffix prevents client LLMs from misreading the
590
- # timestamp as local time.
591
- assert format_current_time().endswith(" UTC")
592
-
593
- def test_format_shape(self):
594
- assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC", format_current_time())
623
+ # switchroom #tz-fix: recall's "Current time - …" line now renders the
624
+ # agent's LOCAL wall clock in am/pm, NOT UTC — it was the strongest of
625
+ # several competing UTC "now" strings the model saw each turn.
626
+ def test_local_am_pm_shape(self, monkeypatch):
627
+ # A real IANA zone → am/pm form with the zone abbreviation, no "UTC".
628
+ monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
629
+ monkeypatch.delenv("TZ", raising=False)
630
+ out = format_current_time()
631
+ # e.g. "2026-07-16 04:09 PM AEST" / "… AEDT" (DST) — abbrev is 3-5 alpha.
632
+ assert re.fullmatch(
633
+ r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM) [A-Za-z]{2,5}", out
634
+ ), out
635
+
636
+ def test_never_emits_utc_current_time(self, monkeypatch):
637
+ # The whole point of the fix: no UTC "current time" for a real zone.
638
+ monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
639
+ monkeypatch.delenv("TZ", raising=False)
640
+ out = format_current_time()
641
+ assert "UTC" not in out
642
+ assert not out.endswith("Z")
643
+
644
+ def test_am_pm_when_no_zone_configured(self, monkeypatch):
645
+ # No SWITCHROOM_TIMEZONE / TZ → still am/pm shape (process-local clock),
646
+ # never the old "%Y-%m-%d %H:%M UTC" 24h form.
647
+ monkeypatch.delenv("SWITCHROOM_TIMEZONE", raising=False)
648
+ monkeypatch.delenv("TZ", raising=False)
649
+ out = format_current_time()
650
+ assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM)", out), out