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