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.
- package/bin/handoff-briefing.sh +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/agent-scheduler/index.js +65 -2
- package/dist/auth-broker/index.js +204 -24
- package/dist/cli/notion-write-pretool.mjs +65 -2
- 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 +3699 -2110
- package/dist/host-control/main.js +67 -4
- package/dist/vault/approvals/kernel-server.js +66 -3
- package/dist/vault/broker/server.js +66 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- 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/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/dist/gateway/gateway.js +1414 -918
- 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 +85 -90
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- 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 +48 -32
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -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/privacy-reset-call-sites.test.ts +120 -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/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- 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_self_improve_correction_tag.py +167 -0
- 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()
|