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.
- package/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- 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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|