switchroom 0.20.9 → 0.20.11

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 (59) hide show
  1. package/bin/handoff-briefing.sh +57 -5
  2. package/bin/working-state-reload-hook.sh +262 -0
  3. package/dist/agent-scheduler/index.js +65 -2
  4. package/dist/auth-broker/index.js +204 -24
  5. package/dist/cli/notion-write-pretool.mjs +65 -2
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  7. package/dist/cli/self-improve-stop.mjs +889 -7
  8. package/dist/cli/skill-validate-pretool.mjs +82 -3
  9. package/dist/cli/switchroom.js +3699 -2110
  10. package/dist/host-control/main.js +67 -4
  11. package/dist/vault/approvals/kernel-server.js +66 -3
  12. package/dist/vault/broker/server.js +66 -3
  13. package/package.json +1 -1
  14. package/profiles/_base/start.sh.hbs +49 -0
  15. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  16. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  17. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  19. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  20. package/profiles/default/CLAUDE.md.hbs +3 -5
  21. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  22. package/telegram-plugin/dist/gateway/gateway.js +1414 -918
  23. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  24. package/telegram-plugin/gateway/auth-command.ts +11 -1
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  26. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  27. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  28. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  29. package/telegram-plugin/gateway/gateway.ts +85 -90
  30. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  31. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  32. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  33. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  34. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  35. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  36. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  37. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  38. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  39. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  40. package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
  41. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -5
  43. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  44. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  45. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  46. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  47. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  48. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  49. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  50. package/telegram-plugin/throttle-tier.ts +12 -3
  51. package/telegram-plugin/turn-flush-safety.ts +97 -0
  52. package/vendor/hindsight-memory/CHANGELOG.md +31 -0
  53. package/vendor/hindsight-memory/hooks/hooks.json +2 -1
  54. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  55. package/vendor/hindsight-memory/scripts/session_start.py +35 -8
  56. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  57. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
@@ -0,0 +1,415 @@
1
+ """Switchroom /private-mode — retain-side enforcement tests (privacy PR1).
2
+
3
+ The Telegram gateway's ``/private`` / ``/public`` commands pause/resume
4
+ auto-retain by maintaining a shared interval file,
5
+ ``${TELEGRAM_STATE_DIR}/privacy-state.json``::
6
+
7
+ {"version": 1, "intervals": [
8
+ {"start": "<iso>", "end": "<iso>"}, # a CLOSED private window
9
+ {"start": "<iso>", "end": null} # the OPEN window = private NOW
10
+ ]}
11
+
12
+ This suite is the enforcement half — the privacy GUARANTEE — and must be
13
+ CI-provable on its own. Every test is written to FAIL on a broken
14
+ implementation (privacy check missing, mis-ordered, or force-path unguarded).
15
+
16
+ Stdlib-only; runs under ``python3 -m unittest discover`` from ``scripts/``.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ import unittest
24
+ from unittest import mock
25
+
26
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
27
+ if SCRIPTS_DIR not in sys.path:
28
+ sys.path.insert(0, SCRIPTS_DIR)
29
+
30
+ import retain # noqa: E402
31
+ from retain import ( # noqa: E402
32
+ _has_open_interval,
33
+ exclude_private_ranges,
34
+ read_privacy_state,
35
+ run_retain,
36
+ )
37
+ from subagent_retain import run_subagent_retain # noqa: E402
38
+
39
+
40
+ def _write_state(state_dir: str, intervals: list) -> None:
41
+ with open(os.path.join(state_dir, "privacy-state.json"), "w", encoding="utf-8") as f:
42
+ json.dump({"version": 1, "intervals": intervals}, f)
43
+
44
+
45
+ def _msg(role: str, text: str, ts: str | None = None) -> dict:
46
+ m = {"role": role, "content": text}
47
+ if ts is not None:
48
+ m["timestamp"] = ts
49
+ return m
50
+
51
+
52
+ # Reusable timestamps (UTC, the gateway's ...Z form).
53
+ T00 = "2026-08-06T02:00:00.000Z" # public (before any interval)
54
+ T01 = "2026-08-06T02:01:00.000Z" # inside a private window
55
+ T02 = "2026-08-06T02:02:00.000Z" # inside a private window
56
+ T03 = "2026-08-06T02:03:00.000Z" # public (after a closed window)
57
+ T04 = "2026-08-06T02:04:00.000Z" # public (after a closed window)
58
+
59
+
60
+ class ReadPrivacyState(unittest.TestCase):
61
+ """read_privacy_state is best-effort and never raises, and — review MAJOR 1
62
+ — distinguishes an ABSENT file (public) from a PRESENT-but-corrupt one
63
+ (fail toward privacy)."""
64
+
65
+ def test_missing_file_is_public(self):
66
+ with tempfile.TemporaryDirectory() as d:
67
+ self.assertEqual(read_privacy_state(d), [])
68
+ # An absent file is genuinely public — not private-now.
69
+ self.assertFalse(_has_open_interval(read_privacy_state(d)))
70
+
71
+ def test_no_intervals_key_is_public(self):
72
+ # Valid JSON object without an intervals key = public (not corrupt).
73
+ with tempfile.TemporaryDirectory() as d:
74
+ with open(os.path.join(d, "privacy-state.json"), "w") as f:
75
+ f.write('{"version": 1}')
76
+ self.assertEqual(read_privacy_state(d), [])
77
+ self.assertFalse(_has_open_interval(read_privacy_state(d)))
78
+
79
+ def test_corrupt_file_fails_toward_privacy(self):
80
+ # MAJOR 1: a file that EXISTS but cannot be parsed (e.g. a Stop hook
81
+ # firing during a non-atomic gateway rewrite) must NOT silently read as
82
+ # public — that would leak the just-completed private turn. It fails
83
+ # TOWARD privacy: private-now, and redaction drops everything.
84
+ with tempfile.TemporaryDirectory() as d:
85
+ with open(os.path.join(d, "privacy-state.json"), "w") as f:
86
+ f.write("{ not json")
87
+ state = read_privacy_state(d)
88
+ self.assertNotEqual(state, [], "corrupt file must not read as public []")
89
+ self.assertTrue(_has_open_interval(state))
90
+ self.assertEqual(
91
+ exclude_private_ranges([_msg("user", "x", T00)], state), []
92
+ )
93
+
94
+ def test_intervals_not_a_list_fails_toward_privacy(self):
95
+ with tempfile.TemporaryDirectory() as d:
96
+ with open(os.path.join(d, "privacy-state.json"), "w") as f:
97
+ f.write('{"version": 1, "intervals": "nope"}')
98
+ self.assertTrue(_has_open_interval(read_privacy_state(d)))
99
+
100
+ def test_reads_intervals(self):
101
+ with tempfile.TemporaryDirectory() as d:
102
+ _write_state(d, [{"start": T01, "end": T02}])
103
+ self.assertEqual(read_privacy_state(d), [{"start": T01, "end": T02}])
104
+
105
+
106
+ class ExcludePrivateRanges(unittest.TestCase):
107
+ """The pure redaction function."""
108
+
109
+ def test_no_intervals_keeps_all(self):
110
+ msgs = [_msg("user", "a", T00), _msg("assistant", "b", T01)]
111
+ self.assertEqual(exclude_private_ranges(msgs, []), msgs)
112
+
113
+ def test_open_interval_drops_from_start_onward(self):
114
+ msgs = [
115
+ _msg("user", "keep", T00),
116
+ _msg("user", "drop1", T01),
117
+ _msg("user", "drop2", T02),
118
+ ]
119
+ kept = exclude_private_ranges(msgs, [{"start": T01, "end": None}])
120
+ self.assertEqual([m["content"] for m in kept], ["keep"])
121
+
122
+ def test_closed_interval_drops_only_inside(self):
123
+ msgs = [
124
+ _msg("user", "keep_before", T00),
125
+ _msg("user", "drop_inside", T01),
126
+ _msg("user", "keep_after", T03),
127
+ ]
128
+ kept = exclude_private_ranges(msgs, [{"start": T01, "end": T02}])
129
+ self.assertEqual(
130
+ [m["content"] for m in kept], ["keep_before", "keep_after"]
131
+ )
132
+
133
+ def test_missing_timestamp_dropped_only_when_open(self):
134
+ no_ts = _msg("user", "no_ts")
135
+ # Closed interval only -> a placeless message is KEPT.
136
+ self.assertIn(
137
+ no_ts, exclude_private_ranges([no_ts], [{"start": T01, "end": T02}])
138
+ )
139
+ # An OPEN interval exists -> conservative drop.
140
+ self.assertEqual(
141
+ exclude_private_ranges([no_ts], [{"start": T01, "end": None}]), []
142
+ )
143
+
144
+ def test_malformed_end_guard_and_redaction_agree(self):
145
+ # Review MINOR: a present-but-unparseable `end` must be interpreted the
146
+ # SAME way by the guard (_has_open_interval) and the redactor
147
+ # (exclude_private_ranges) — both treat it as OPEN [start, ∞). Previously
148
+ # the guard read it as closed (end is not None) while the redactor
149
+ # degraded it to unbounded-open, silently deleting all public memory from
150
+ # `start` onward with no skip and no log.
151
+ intervals = [{"start": T01, "end": "GARBAGE"}]
152
+ self.assertTrue(_has_open_interval(intervals)) # guard sees OPEN
153
+ msgs = [
154
+ _msg("user", "keep_before", T00),
155
+ _msg("user", "drop_at_start", T01),
156
+ _msg("user", "drop_after", T03),
157
+ ]
158
+ kept = exclude_private_ranges(msgs, intervals)
159
+ # Redactor also sees OPEN [T01, ∞): before T01 kept, T01+ dropped.
160
+ self.assertEqual([m["content"] for m in kept], ["keep_before"])
161
+
162
+ def test_unparseable_start_fails_toward_privacy(self):
163
+ # A present-but-unparseable start can't be placed -> drop everything.
164
+ intervals = [{"start": "GARBAGE", "end": None}]
165
+ self.assertTrue(_has_open_interval(intervals))
166
+ self.assertEqual(
167
+ exclude_private_ranges([_msg("user", "x", T00)], intervals), []
168
+ )
169
+
170
+
171
+ # --- run_retain wiring: network-layer stubs so we can inspect the payload -----
172
+
173
+
174
+ def _base_config(**over) -> dict:
175
+ cfg = {
176
+ "autoRetain": True,
177
+ "retainMode": "chunked",
178
+ "retainEveryNTurns": 1,
179
+ "retainOverlapTurns": 50, # window wide enough to keep all public turns
180
+ "retainRoles": ["user", "assistant"],
181
+ "retainToolCalls": True,
182
+ "retainTags": [],
183
+ }
184
+ cfg.update(over)
185
+ return cfg
186
+
187
+
188
+ class _FakeClient:
189
+ def __init__(self, *a, **k):
190
+ pass
191
+
192
+ def retain(self, **kwargs): # noqa: D401
193
+ _FakeClient.captured = kwargs
194
+ return {}
195
+
196
+
197
+ def _run_retain_capturing_payload(state_dir, messages, *, force, config=None):
198
+ """Run run_retain with the network layer stubbed; return the retain content
199
+ string that was actually built and POSTed (or None if none was)."""
200
+ _FakeClient.captured = None
201
+ cm = mock.MagicMock()
202
+ cm.__enter__.return_value = True
203
+ cm.__exit__.return_value = False
204
+ hook_input = {"session_id": "s1", "transcript_path": "/x.jsonl"}
205
+ with mock.patch.dict(os.environ, {"TELEGRAM_STATE_DIR": state_dir}), \
206
+ mock.patch("retain.load_config", return_value=config or _base_config()), \
207
+ mock.patch("retain.read_transcript", return_value=list(messages)), \
208
+ mock.patch("retain.get_api_url", return_value="http://localhost:1"), \
209
+ mock.patch("retain.HindsightClient", _FakeClient), \
210
+ mock.patch("retain.ensure_bank_mission"), \
211
+ mock.patch("retain.derive_bank_id", return_value="bank"), \
212
+ mock.patch("retain.track_retention", return_value=(0, False)), \
213
+ mock.patch("retain.increment_turn_count", return_value=1), \
214
+ mock.patch("retain.inflight_lock", return_value=cm), \
215
+ mock.patch("retain.watermark") as wm:
216
+ wm.commit.return_value = None
217
+ result = run_retain(hook_input, force=force)
218
+ captured = _FakeClient.captured
219
+ return result, (captured["content"] if captured else None)
220
+
221
+
222
+ class EnvPinCannotOverridePrivacy(unittest.TestCase):
223
+ """A HINDSIGHT_AUTO_RETAIN=true env pin (autoRetain forced TRUE) must NOT be
224
+ able to override an OPEN private interval. The privacy check runs BEFORE
225
+ load_config(), so the pin never gets a say."""
226
+
227
+ def test_env_pin_open_interval_skips_private_mode(self):
228
+ with tempfile.TemporaryDirectory() as d:
229
+ _write_state(d, [{"start": T01, "end": None}])
230
+ hook_input = {"session_id": "s1", "transcript_path": "/x.jsonl"}
231
+ with mock.patch.dict(
232
+ os.environ,
233
+ {"TELEGRAM_STATE_DIR": d, "HINDSIGHT_AUTO_RETAIN": "true"},
234
+ ), mock.patch(
235
+ "retain.load_config", return_value=_base_config(autoRetain=True)
236
+ ), mock.patch("retain.read_transcript") as read_t:
237
+ result = run_retain(hook_input, force=False)
238
+ # Skipped for privacy specifically...
239
+ self.assertEqual(result.get("reason"), "private-mode")
240
+ # ...and BEFORE the transcript was ever read (early, pre-load_config).
241
+ read_t.assert_not_called()
242
+
243
+
244
+ class PrivacyRunsBeforeAutoRetainGate(unittest.TestCase):
245
+ """Ordering: even when autoRetain is FALSE, an open interval yields the
246
+ private-mode reason — proving the privacy check runs before (and independent
247
+ of) the autoRetain gate, not after it."""
248
+
249
+ def test_open_interval_reports_private_mode_not_autoretain_disabled(self):
250
+ with tempfile.TemporaryDirectory() as d:
251
+ _write_state(d, [{"start": T01, "end": None}])
252
+ hook_input = {"session_id": "s1", "transcript_path": "/x.jsonl"}
253
+ with mock.patch.dict(os.environ, {"TELEGRAM_STATE_DIR": d}), \
254
+ mock.patch(
255
+ "retain.load_config",
256
+ return_value=_base_config(autoRetain=False),
257
+ ):
258
+ result = run_retain(hook_input, force=False)
259
+ self.assertEqual(result.get("reason"), "private-mode")
260
+
261
+
262
+ class ForcePathExcludesOpenRange(unittest.TestCase):
263
+ """The assertion the old fire-skipping design could NOT pass: a FORCED
264
+ SessionEnd sweep is exempt from the early-skip (it must flush the public
265
+ portion), so the private turns must be excluded from the built payload
266
+ instead of the whole fire being skipped."""
267
+
268
+ def test_force_sweep_omits_open_range_messages(self):
269
+ with tempfile.TemporaryDirectory() as d:
270
+ _write_state(d, [{"start": T01, "end": None}])
271
+ messages = [
272
+ _msg("user", "PUBLIC_BEFORE_TOKEN", T00),
273
+ _msg("assistant", "ok public", T00),
274
+ _msg("user", "PRIVATE_SECRET_TOKEN", T01),
275
+ _msg("assistant", "PRIVATE_REPLY_TOKEN", T02),
276
+ ]
277
+ result, content = _run_retain_capturing_payload(
278
+ d, messages, force=True
279
+ )
280
+ self.assertIsNotNone(content, "force sweep should still POST the public portion")
281
+ self.assertIn("PUBLIC_BEFORE_TOKEN", content)
282
+ self.assertNotIn("PRIVATE_SECRET_TOKEN", content)
283
+ self.assertNotIn("PRIVATE_REPLY_TOKEN", content)
284
+
285
+
286
+ class ChunkedExcludesClosedRange(unittest.TestCase):
287
+ """A normal (non-forced) chunked retain AFTER a CLOSED private window must
288
+ exclude the messages that fell inside that window, even though they are
289
+ inside the sliding window."""
290
+
291
+ def test_closed_range_messages_absent_from_window(self):
292
+ with tempfile.TemporaryDirectory() as d:
293
+ _write_state(d, [{"start": T01, "end": T02}])
294
+ messages = [
295
+ _msg("user", "PUBLIC_OLD_TOKEN", T00),
296
+ _msg("user", "PRIVATE_MID_TOKEN", T01),
297
+ _msg("assistant", "PRIVATE_MID_REPLY", T02),
298
+ _msg("user", "PUBLIC_NEW_TOKEN", T03),
299
+ _msg("assistant", "PUBLIC_NEW_REPLY", T04),
300
+ ]
301
+ result, content = _run_retain_capturing_payload(
302
+ d, messages, force=False
303
+ )
304
+ self.assertIsNotNone(content)
305
+ self.assertIn("PUBLIC_OLD_TOKEN", content)
306
+ self.assertIn("PUBLIC_NEW_TOKEN", content)
307
+ self.assertNotIn("PRIVATE_MID_TOKEN", content)
308
+ self.assertNotIn("PRIVATE_MID_REPLY", content)
309
+
310
+
311
+ class CorruptStateFailsTowardPrivacy(unittest.TestCase):
312
+ """Review MAJOR 1 at the run_retain level: a present-but-corrupt state file
313
+ mid-session skips the (non-forced) retain toward privacy, while a genuinely
314
+ ABSENT file retains normally."""
315
+
316
+ def test_corrupt_file_skips_non_forced_retain(self):
317
+ with tempfile.TemporaryDirectory() as d:
318
+ with open(os.path.join(d, "privacy-state.json"), "w") as f:
319
+ f.write("{ truncated mid-rewrite")
320
+ hook_input = {"session_id": "s1", "transcript_path": "/x.jsonl"}
321
+ with mock.patch.dict(os.environ, {"TELEGRAM_STATE_DIR": d}), \
322
+ mock.patch("retain.load_config", return_value=_base_config()), \
323
+ mock.patch("retain.read_transcript") as read_t:
324
+ result = run_retain(hook_input, force=False)
325
+ self.assertEqual(result.get("reason"), "private-mode")
326
+ read_t.assert_not_called() # early skip, before the transcript is read
327
+
328
+ def test_absent_file_retains_normally(self):
329
+ # No privacy-state.json in the dir -> genuinely public -> full retain.
330
+ with tempfile.TemporaryDirectory() as d:
331
+ messages = [
332
+ _msg("user", "PUBLIC_A_TOKEN", T00),
333
+ _msg("assistant", "PUBLIC_B_TOKEN", T01),
334
+ ]
335
+ result, content = _run_retain_capturing_payload(
336
+ d, messages, force=False
337
+ )
338
+ self.assertIsNotNone(content, "absent file must retain normally")
339
+ self.assertIn("PUBLIC_A_TOKEN", content)
340
+ self.assertIn("PUBLIC_B_TOKEN", content)
341
+
342
+
343
+ def _run_subagent_capturing_window(state_dir, messages):
344
+ """Run run_subagent_retain with the network layer stubbed; return the
345
+ messages_to_retain slice that build_retain_payload was handed (the window
346
+ that would be formatted + POSTed)."""
347
+ captured = {}
348
+
349
+ def _capture(config, session_id, messages_to_retain, all_messages, **kw):
350
+ captured["window"] = list(messages_to_retain)
351
+ return None # short-circuit before the POST; we only need the window
352
+
353
+ hook_input = {"session_id": "s1", "agent_id": "a1"}
354
+ with mock.patch.dict(os.environ, {"TELEGRAM_STATE_DIR": state_dir}), \
355
+ mock.patch("subagent_retain.load_config", return_value=_base_config()), \
356
+ mock.patch(
357
+ "subagent_retain.resolve_sidechain_transcript",
358
+ return_value="/x.jsonl",
359
+ ), \
360
+ mock.patch("subagent_retain.read_transcript", return_value=list(messages)), \
361
+ mock.patch(
362
+ "subagent_retain.passes_volume_gate", return_value=(True, 3, 9999)
363
+ ), \
364
+ mock.patch("subagent_retain.get_api_url", return_value="http://localhost:1"), \
365
+ mock.patch("subagent_retain.HindsightClient", _FakeClient), \
366
+ mock.patch("subagent_retain.ensure_bank_mission"), \
367
+ mock.patch("subagent_retain.derive_bank_id", return_value="bank"), \
368
+ mock.patch("subagent_retain.build_retain_payload", side_effect=_capture):
369
+ run_subagent_retain(hook_input)
370
+ window = captured.get("window", [])
371
+ texts = [m.get("content") for m in window if isinstance(m, dict)]
372
+ return "\n".join(t for t in texts if isinstance(t, str))
373
+
374
+
375
+ class SubagentExcludesClosedRange(unittest.TestCase):
376
+ """Review MAJOR 2: a backgrounded sub-agent dispatched under /private that
377
+ finishes AFTER /public closed the interval must still have its private-window
378
+ material redacted — the open-interval early-skip alone doesn't catch it."""
379
+
380
+ def test_closed_range_absent_from_subagent_window(self):
381
+ with tempfile.TemporaryDirectory() as d:
382
+ _write_state(d, [{"start": T01, "end": T02}]) # now CLOSED
383
+ messages = [
384
+ _msg("user", "SUB_PUBLIC_BEFORE", T00),
385
+ _msg("user", "SUB_PRIVATE_MID", T01),
386
+ _msg("assistant", "SUB_PRIVATE_REPLY", T02),
387
+ _msg("user", "SUB_PUBLIC_AFTER", T03),
388
+ _msg("assistant", "SUB_PUBLIC_DONE", T04),
389
+ ]
390
+ window = _run_subagent_capturing_window(d, messages)
391
+ self.assertIn("SUB_PUBLIC_AFTER", window)
392
+ self.assertNotIn("SUB_PRIVATE_MID", window)
393
+ self.assertNotIn("SUB_PRIVATE_REPLY", window)
394
+
395
+
396
+ class SubagentHonorsPrivacy(unittest.TestCase):
397
+ """Subagents have no toggle of their own; they honor the parent session's
398
+ privacy state file. An open interval skips the sidechain retain."""
399
+
400
+ def test_subagent_open_interval_skips_private_mode(self):
401
+ with tempfile.TemporaryDirectory() as d:
402
+ _write_state(d, [{"start": T01, "end": None}])
403
+ hook_input = {"session_id": "s1", "agent_id": "a1"}
404
+ with mock.patch.dict(os.environ, {"TELEGRAM_STATE_DIR": d}), \
405
+ mock.patch("subagent_retain.load_config") as lc, \
406
+ mock.patch("subagent_retain.read_transcript") as read_t:
407
+ result = run_subagent_retain(hook_input)
408
+ self.assertEqual(result.get("reason"), "private-mode")
409
+ # Early skip runs before load_config() and before any transcript read.
410
+ lc.assert_not_called()
411
+ read_t.assert_not_called()
412
+
413
+
414
+ if __name__ == "__main__":
415
+ unittest.main()
@@ -0,0 +1,167 @@
1
+ """Per-turn operator-correction tag on auto-retained turns (switchroom PR4 4a).
2
+
3
+ When switchroom's self-improve gate (``src/self-improve/gate.ts``) fires on an
4
+ ``operator-correction`` signal, the Stop hook drops a per-turn sentinel into the
5
+ shared agent state dir. This hook — a SEPARATE process — reads-and-clears that
6
+ sentinel and stamps ``self-improve:correction`` on the turn's retain, so PR5's
7
+ failure-synthesis cron can recall correction turns cheaply by tag filter.
8
+
9
+ This module asserts the OUTCOMES at the retain seam:
10
+
11
+ 1. read-and-clear is read-ONCE: a present sentinel yields the tag and is then
12
+ gone; a second read yields nothing.
13
+ 2. the tag reaches the wire payload when (and ONLY when) it is threaded in —
14
+ a mutation that stamped it unconditionally, or never, fails here.
15
+ 3. the tag is STABLE and therefore DOES move the consolidation scope to its
16
+ own ``[["self-improve:correction"]]`` partition — the opposite of the
17
+ forced-volatile ``source:transcript`` tag — and it matches NO volatile
18
+ scope pattern (in particular not ``^source:``). Absent tag ⇒ scope stays
19
+ the byte-identical ``"shared"``.
20
+
21
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import tempfile
27
+ import unittest
28
+
29
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
30
+ if SCRIPTS_DIR not in sys.path:
31
+ sys.path.insert(0, SCRIPTS_DIR)
32
+
33
+ from lib.config import ( # noqa: E402
34
+ DEFAULTS,
35
+ DEFAULT_VOLATILE_SCOPE_PATTERNS,
36
+ compute_observation_scopes,
37
+ )
38
+ from retain import ( # noqa: E402
39
+ SELF_IMPROVE_CORRECTION_PENDING_FILE,
40
+ SELF_IMPROVE_CORRECTION_TAG,
41
+ build_retain_payload,
42
+ read_and_clear_correction_pending,
43
+ )
44
+
45
+ PROVENANCE_TAG = "source:transcript"
46
+ SESSION_ID = "4c386b32-ddfd-40d1-b557-da8135b294af"
47
+
48
+
49
+ def _cfg(**over):
50
+ c = {
51
+ "retainRoles": ["user", "assistant"],
52
+ "retainToolCalls": True,
53
+ "retainContext": "claude-code",
54
+ "retainMetadata": {},
55
+ "retainTags": ["{session_id}", PROVENANCE_TAG],
56
+ "lessonTagging": DEFAULTS["lessonTagging"],
57
+ "lessonTagMarkers": DEFAULTS["lessonTagMarkers"],
58
+ "observationScopeStrategy": "curated",
59
+ }
60
+ c.update(over)
61
+ return c
62
+
63
+
64
+ def _build(extra_tags=None, **cfg_over):
65
+ msgs = [
66
+ {"role": "user", "content": "why did you include drafts?", "uuid": "u1"},
67
+ {"role": "assistant", "content": "Fixed the digest filter.", "uuid": "a1"},
68
+ ]
69
+ built = build_retain_payload(
70
+ _cfg(**cfg_over),
71
+ SESSION_ID,
72
+ msgs,
73
+ msgs,
74
+ bank_id="bank",
75
+ api_url="http://x",
76
+ api_token=None,
77
+ extra_tags=extra_tags,
78
+ )
79
+ assert built is not None
80
+ return built["payload"]
81
+
82
+
83
+ class ReadAndClearIsReadOnce(unittest.TestCase):
84
+ def test_present_sentinel_yields_the_tag_and_is_cleared(self):
85
+ with tempfile.TemporaryDirectory() as d:
86
+ path = os.path.join(d, SELF_IMPROVE_CORRECTION_PENDING_FILE)
87
+ with open(path, "w") as f:
88
+ f.write("2026-08-06T00:00:00Z")
89
+ self.assertTrue(os.path.exists(path))
90
+
91
+ first = read_and_clear_correction_pending(d)
92
+ self.assertEqual(first, [SELF_IMPROVE_CORRECTION_TAG])
93
+ # Cleared as a side effect — read-once.
94
+ self.assertFalse(os.path.exists(path))
95
+
96
+ # A second read finds nothing: exactly one retain carries the tag.
97
+ second = read_and_clear_correction_pending(d)
98
+ self.assertEqual(second, [])
99
+
100
+ def test_absent_sentinel_yields_no_tag(self):
101
+ with tempfile.TemporaryDirectory() as d:
102
+ self.assertEqual(read_and_clear_correction_pending(d), [])
103
+
104
+ def test_never_raises_on_a_bad_state_dir(self):
105
+ # A nonexistent dir must degrade to "no tag", never raise.
106
+ self.assertEqual(
107
+ read_and_clear_correction_pending("/nonexistent/does/not/exist"),
108
+ [],
109
+ )
110
+
111
+
112
+ class CorrectionTagReachesTheWire(unittest.TestCase):
113
+ def test_tag_on_payload_when_threaded_in(self):
114
+ tags = _build(extra_tags=[SELF_IMPROVE_CORRECTION_TAG])["tags"]
115
+ self.assertIn(SELF_IMPROVE_CORRECTION_TAG, tags)
116
+ # It composes — it does not clobber the session / provenance tags.
117
+ self.assertIn(SESSION_ID, tags)
118
+ self.assertIn(PROVENANCE_TAG, tags)
119
+
120
+ def test_tag_absent_when_not_threaded_in(self):
121
+ # Mutation guard: the tag is DATA carried by extra_tags, not unconditional
122
+ # behaviour. A normal turn carries no correction tag.
123
+ for extra in (None, []):
124
+ tags = _build(extra_tags=extra)["tags"]
125
+ self.assertNotIn(SELF_IMPROVE_CORRECTION_TAG, tags)
126
+
127
+ def test_no_duplicate_when_already_present(self):
128
+ tags = _build(
129
+ extra_tags=[SELF_IMPROVE_CORRECTION_TAG, SELF_IMPROVE_CORRECTION_TAG]
130
+ )["tags"]
131
+ self.assertEqual(tags.count(SELF_IMPROVE_CORRECTION_TAG), 1)
132
+
133
+
134
+ class CorrectionTagScopeContract(unittest.TestCase):
135
+ """The tag is STABLE — it SHOULD partition scope, and matches no volatile pat."""
136
+
137
+ def test_tag_matches_no_volatile_scope_pattern(self):
138
+ import re
139
+
140
+ for pat in DEFAULT_VOLATILE_SCOPE_PATTERNS:
141
+ self.assertIsNone(
142
+ re.search(pat, SELF_IMPROVE_CORRECTION_TAG),
143
+ f"correction tag must be STABLE but matched volatile pattern {pat!r}",
144
+ )
145
+
146
+ def test_correction_turn_lands_in_its_own_scope(self):
147
+ scope = _build(extra_tags=[SELF_IMPROVE_CORRECTION_TAG])["observation_scopes"]
148
+ self.assertEqual(scope, [[SELF_IMPROVE_CORRECTION_TAG]])
149
+
150
+ def test_normal_turn_scope_stays_shared_and_byte_identical(self):
151
+ with_none = _build(extra_tags=None)["observation_scopes"]
152
+ with_empty = _build(extra_tags=[])["observation_scopes"]
153
+ self.assertEqual(with_none, "shared")
154
+ self.assertEqual(with_empty, "shared")
155
+
156
+ def test_compute_scope_directly_partitions_on_the_stable_tag(self):
157
+ # Guards the guard: prove the [[tag]] result is caused by the tag being
158
+ # stable, straight through compute_observation_scopes.
159
+ scope, err = compute_observation_scopes(
160
+ [SESSION_ID, PROVENANCE_TAG, SELF_IMPROVE_CORRECTION_TAG], _cfg()
161
+ )
162
+ self.assertIsNone(err)
163
+ self.assertEqual(scope, [[SELF_IMPROVE_CORRECTION_TAG]])
164
+
165
+
166
+ if __name__ == "__main__": # pragma: no cover
167
+ unittest.main()
@@ -0,0 +1,107 @@
1
+ """The SessionStart hook must still DO its durability work.
2
+
3
+ The hook was made ``"async": true`` (hooks/hooks.json) so a stacked
4
+ drain + reconcile + health-probe budget can no longer overrun the
5
+ SessionStart timeout and get the process SIGKILLed mid-drain. Async only
6
+ helps if the two durability calls are still MADE on the healthy path —
7
+ if a refactor drops one, the queue silently stops draining and abrupt-kill
8
+ turns stop being recovered, which is the exact outage the hook exists to
9
+ prevent and which no attachment record would surface (a successful
10
+ SessionStart hook that injects no context leaves no transcript trace).
11
+
12
+ So this pins the OUTCOME, not the wiring: on a reachable server,
13
+ ``session_start.main()`` invokes ``drain_pending.drain`` and then
14
+ ``reconcile_tail.reconcile``, in that order (reconcile runs AFTER the
15
+ drain by design — it recovers what SessionEnd never managed to enqueue).
16
+
17
+ Lives under ``scripts/tests/`` because that is the only python test
18
+ directory CI discovers (``ci-tests-python.yml`` runs ``unittest discover``
19
+ from ``vendor/hindsight-memory/scripts``).
20
+ """
21
+
22
+ import io
23
+ import os
24
+ import sys
25
+ import unittest
26
+ import unittest.mock
27
+
28
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
29
+ if SCRIPTS_DIR not in sys.path:
30
+ sys.path.insert(0, SCRIPTS_DIR)
31
+
32
+ import drain_pending # noqa: E402
33
+ import reconcile_tail # noqa: E402
34
+ import session_start # noqa: E402
35
+
36
+
37
+ class _ReachableClient:
38
+ """Stand-in for HindsightClient — reachable, no network."""
39
+
40
+ def __init__(self, *_a, **_kw):
41
+ pass
42
+
43
+ def health_check(self, timeout=5, retries=3):
44
+ return True
45
+
46
+
47
+ class DurabilityWorkStillRunsTest(unittest.TestCase):
48
+ CONFIG = {"autoRetain": True, "autoRecall": True}
49
+
50
+ def _run_main(self, config=None):
51
+ """Run ``session_start.main()`` against a reachable server with the
52
+ two durability calls stubbed, returning the ordered call log."""
53
+ calls = []
54
+
55
+ def fake_drain(cfg):
56
+ calls.append("drain")
57
+
58
+ def fake_reconcile(cfg, hook_input=None):
59
+ calls.append("reconcile")
60
+
61
+ cfg = dict(self.CONFIG if config is None else config)
62
+ with unittest.mock.patch.object(drain_pending, "drain", fake_drain), \
63
+ unittest.mock.patch.object(reconcile_tail, "reconcile", fake_reconcile), \
64
+ unittest.mock.patch.object(session_start, "load_config", lambda: cfg), \
65
+ unittest.mock.patch.object(
66
+ session_start,
67
+ "get_api_url",
68
+ lambda c, debug_fn=None, allow_daemon_start=True: (
69
+ "http://127.0.0.1:9/none"
70
+ ),
71
+ ), \
72
+ unittest.mock.patch.object(
73
+ session_start, "HindsightClient", _ReachableClient
74
+ ), \
75
+ unittest.mock.patch.object(sys, "stdin", io.StringIO("{}")):
76
+ session_start.main()
77
+ return calls
78
+
79
+ def test_drain_and_reconcile_both_run_on_a_reachable_server(self):
80
+ calls = self._run_main()
81
+ self.assertIn("drain", calls, "queued retains must still be drained")
82
+ self.assertIn(
83
+ "reconcile",
84
+ calls,
85
+ "un-committed abrupt-kill turns must still be reconciled",
86
+ )
87
+
88
+ def test_reconcile_runs_after_the_drain(self):
89
+ calls = self._run_main()
90
+ self.assertEqual(
91
+ calls,
92
+ ["drain", "reconcile"],
93
+ "reconcile recovers what SessionEnd never enqueued, so it must "
94
+ "run AFTER the drain replays what it did",
95
+ )
96
+
97
+ def test_disabled_memory_skips_both(self):
98
+ """The control case: with both autoRecall and autoRetain off, the
99
+ hook returns before touching the durability path. Without this the
100
+ assertions above could be satisfied by calls that fire
101
+ unconditionally."""
102
+ calls = self._run_main(config={"autoRetain": False, "autoRecall": False})
103
+ self.assertEqual(calls, [])
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()