switchroom 0.20.10 → 0.20.12

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +68 -3
  2. package/dist/auth-broker/index.js +211 -46
  3. package/dist/cli/notion-write-pretool.mjs +68 -3
  4. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  5. package/dist/cli/self-improve-stop.mjs +889 -7
  6. package/dist/cli/skill-validate-pretool.mjs +82 -3
  7. package/dist/cli/switchroom.js +5063 -2961
  8. package/dist/host-control/main.js +93 -27
  9. package/dist/vault/approvals/kernel-server.js +92 -26
  10. package/dist/vault/broker/server.js +92 -26
  11. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  14. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  15. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  17. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  18. package/profiles/default/CLAUDE.md.hbs +3 -5
  19. package/skills/switchroom-architecture/telegram.md +0 -1
  20. package/skills/switchroom-cli/SKILL.md +0 -1
  21. package/telegram-plugin/README.md +2 -11
  22. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  23. package/telegram-plugin/bridge/bridge.ts +0 -12
  24. package/telegram-plugin/chat-lock.ts +1 -1
  25. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +1454 -976
  27. package/telegram-plugin/dist/server.js +0 -12
  28. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  29. package/telegram-plugin/gateway/auth-command.ts +11 -1
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  31. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  32. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  33. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  34. package/telegram-plugin/gateway/gateway.ts +109 -149
  35. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  36. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  37. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  38. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  39. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  40. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  41. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  42. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  43. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  44. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  45. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  46. package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
  47. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  48. package/telegram-plugin/gateway/stream-render.ts +4 -4
  49. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  50. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  51. package/telegram-plugin/hooks/hooks.json +13 -12
  52. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  53. package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
  54. package/telegram-plugin/slot-banner-driver.ts +42 -5
  55. package/telegram-plugin/status-pin.ts +2 -5
  56. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  57. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  58. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  59. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  60. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  61. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  62. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  63. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  64. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  65. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  66. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  68. package/telegram-plugin/throttle-tier.ts +12 -3
  69. package/telegram-plugin/turn-flush-safety.ts +97 -0
  70. package/telegram-plugin/worker-activity-feed.ts +1 -1
  71. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  72. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  73. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  74. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -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()