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,277 @@
|
|
|
1
|
+
"""Switchroom recall-latency instrumentation tests.
|
|
2
|
+
|
|
3
|
+
recall.py is the UserPromptSubmit hook in front of every reply, and until now
|
|
4
|
+
its wall time was UNMEASURED: it is a direct plugin hook (not wrapped by
|
|
5
|
+
bin/run-hook.sh, so no hook-timings row) and recall_log.jsonl carried no
|
|
6
|
+
duration field. These tests lock in the two things this PR added:
|
|
7
|
+
|
|
8
|
+
1. Every recall_log row (cache hit AND cache miss) carries a numeric
|
|
9
|
+
`duration_ms` — including a degraded/failed recall.
|
|
10
|
+
2. The hook emits a `hook-timings-<Ddd>.log` line matching bin/run-hook.sh's
|
|
11
|
+
schema, into the same weekday ring, honouring the same env knobs.
|
|
12
|
+
3. The instrumentation NEVER pollutes the hook's stdout contract (the
|
|
13
|
+
recall-context JSON Claude Code consumes is byte-identical).
|
|
14
|
+
|
|
15
|
+
Stdlib-only (unittest + mock). Reuses the integration harness's fakes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
import unittest
|
|
25
|
+
from unittest.mock import patch
|
|
26
|
+
|
|
27
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
28
|
+
if SCRIPTS_DIR not in sys.path:
|
|
29
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
30
|
+
TESTS_DIR = os.path.abspath(os.path.dirname(__file__))
|
|
31
|
+
if TESTS_DIR not in sys.path:
|
|
32
|
+
sys.path.insert(0, TESTS_DIR)
|
|
33
|
+
|
|
34
|
+
import recall # noqa: E402
|
|
35
|
+
from test_recall_integration import _FakeClient, _memory, _run_main_with # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DurationMsInRecallLogTests(unittest.TestCase):
|
|
39
|
+
"""recall_log.jsonl rows must carry a numeric `duration_ms`."""
|
|
40
|
+
|
|
41
|
+
def setUp(self):
|
|
42
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-latency-test-")
|
|
43
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
44
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
45
|
+
|
|
46
|
+
def tearDown(self):
|
|
47
|
+
import shutil
|
|
48
|
+
|
|
49
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
50
|
+
if self._prev is None:
|
|
51
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
52
|
+
else:
|
|
53
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
54
|
+
|
|
55
|
+
def _read_log(self):
|
|
56
|
+
path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
|
|
57
|
+
if not os.path.isfile(path):
|
|
58
|
+
return []
|
|
59
|
+
with open(path, encoding="utf-8") as f:
|
|
60
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
61
|
+
|
|
62
|
+
def test_cache_miss_row_has_numeric_duration_ms(self):
|
|
63
|
+
client = _FakeClient(directives=[], memories=[_memory("x", mem_id="x1")])
|
|
64
|
+
_run_main_with(client)
|
|
65
|
+
e = self._read_log()[0]
|
|
66
|
+
self.assertFalse(e["cache_hit"])
|
|
67
|
+
self.assertIn("duration_ms", e)
|
|
68
|
+
self.assertIsInstance(e["duration_ms"], int)
|
|
69
|
+
self.assertGreaterEqual(e["duration_ms"], 0)
|
|
70
|
+
# duration_ms (full hook) must be >= total_elapsed_ms (recall path only):
|
|
71
|
+
# it strictly contains it, so the derived local overhead is never negative.
|
|
72
|
+
self.assertGreaterEqual(e["duration_ms"], e["total_elapsed_ms"])
|
|
73
|
+
|
|
74
|
+
def test_degraded_recall_still_records_a_duration(self):
|
|
75
|
+
# The common case right now: the own bank is unreachable. The row must
|
|
76
|
+
# still carry a numeric duration so a slow/failed recall is attributable.
|
|
77
|
+
client = _FakeClient(
|
|
78
|
+
directives=[],
|
|
79
|
+
memories=[],
|
|
80
|
+
recall_exc=RuntimeError("HTTP 503 from http://localhost:18888"),
|
|
81
|
+
)
|
|
82
|
+
_run_main_with(client)
|
|
83
|
+
e = self._read_log()[0]
|
|
84
|
+
self.assertTrue(e["bank_errored"])
|
|
85
|
+
self.assertEqual(e["result_count"], 0)
|
|
86
|
+
self.assertIsInstance(e["duration_ms"], int)
|
|
87
|
+
self.assertGreaterEqual(e["duration_ms"], 0)
|
|
88
|
+
|
|
89
|
+
def _run_with_real_state(self, client, prompt):
|
|
90
|
+
"""Invoke recall.main WITHOUT stubbing write_state/read_state, so the
|
|
91
|
+
per-session recall cache actually persists to CLAUDE_PLUGIN_DATA and a
|
|
92
|
+
second identical prompt takes the cache-hit path. (The shared
|
|
93
|
+
_run_main_with harness stubs write_state, which disables the cache.)"""
|
|
94
|
+
hook_input = {
|
|
95
|
+
"prompt": prompt,
|
|
96
|
+
"session_id": "cache-session",
|
|
97
|
+
"transcript_path": "",
|
|
98
|
+
"cwd": "/tmp",
|
|
99
|
+
}
|
|
100
|
+
config = {
|
|
101
|
+
"autoRecall": True,
|
|
102
|
+
"bankId": "test-bank",
|
|
103
|
+
"recallMaxTokens": 1024,
|
|
104
|
+
"recallBudget": "mid",
|
|
105
|
+
"recallContextTurns": 1,
|
|
106
|
+
"recallMaxQueryChars": 800,
|
|
107
|
+
"recallPromptPreamble": "",
|
|
108
|
+
"directivesCacheTtlSeconds": 0,
|
|
109
|
+
}
|
|
110
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
111
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
112
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
113
|
+
recall, "ensure_bank_mission", return_value=None
|
|
114
|
+
), patch("sys.stdin", new=io.StringIO(json.dumps(hook_input))), patch(
|
|
115
|
+
"sys.stdout", new=io.StringIO()
|
|
116
|
+
), patch("sys.stderr", new=io.StringIO()):
|
|
117
|
+
recall.main()
|
|
118
|
+
|
|
119
|
+
def test_cache_hit_row_has_numeric_duration_ms(self):
|
|
120
|
+
# Populate the cache, then hit it on a second identical prompt. The
|
|
121
|
+
# cache-hit row must also carry duration_ms (total_elapsed_ms is None
|
|
122
|
+
# there, so duration_ms is the only latency number on a cache hit).
|
|
123
|
+
client = _FakeClient(directives=[], memories=[_memory("x", mem_id="x1")])
|
|
124
|
+
with patch.dict(os.environ, {"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"}):
|
|
125
|
+
self._run_with_real_state(client, "What did we decide about auth?")
|
|
126
|
+
self._run_with_real_state(client, "What did we decide about auth?")
|
|
127
|
+
rows = self._read_log()
|
|
128
|
+
hit_rows = [r for r in rows if r["cache_hit"]]
|
|
129
|
+
self.assertTrue(hit_rows, "expected at least one cache-hit row")
|
|
130
|
+
e = hit_rows[0]
|
|
131
|
+
self.assertIsNone(e["total_elapsed_ms"])
|
|
132
|
+
self.assertIsInstance(e["duration_ms"], int)
|
|
133
|
+
self.assertGreaterEqual(e["duration_ms"], 0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class StdoutUnchangedTests(unittest.TestCase):
|
|
137
|
+
"""The instrumentation writes only to log files — the hook's stdout
|
|
138
|
+
(the recall-context JSON Claude Code consumes) must be byte-identical."""
|
|
139
|
+
|
|
140
|
+
def test_stdout_is_exact_and_carries_no_instrumentation(self):
|
|
141
|
+
client = _FakeClient(directives=[], memories=[_memory("a stored fact")])
|
|
142
|
+
# Pin the clock-derived preamble so the emitted block is deterministic,
|
|
143
|
+
# letting us assert the stdout bytes EXACTLY.
|
|
144
|
+
with patch.object(recall, "format_current_time", return_value="FIXED-TIME"):
|
|
145
|
+
ctx, raw = _run_main_with(client)
|
|
146
|
+
|
|
147
|
+
expected_context = (
|
|
148
|
+
"<hindsight_memories>\n"
|
|
149
|
+
"\n" # empty recallPromptPreamble
|
|
150
|
+
"Current time - FIXED-TIME\n\n"
|
|
151
|
+
+ recall.format_memories([_memory("a stored fact")])
|
|
152
|
+
+ "\n</hindsight_memories>"
|
|
153
|
+
)
|
|
154
|
+
expected_raw = json.dumps(
|
|
155
|
+
{
|
|
156
|
+
"hookSpecificOutput": {
|
|
157
|
+
"hookEventName": "UserPromptSubmit",
|
|
158
|
+
"additionalContext": expected_context,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
self.assertEqual(raw, expected_raw)
|
|
163
|
+
# Belt-and-braces: no instrumentation artifact leaked into the contract.
|
|
164
|
+
self.assertNotIn("duration_ms", raw)
|
|
165
|
+
self.assertNotIn("hook-timings", raw)
|
|
166
|
+
self.assertNotIn("total_elapsed_ms", raw)
|
|
167
|
+
|
|
168
|
+
def test_empty_recall_emits_empty_stdout(self):
|
|
169
|
+
# A no-directive, no-memory turn still emits nothing on stdout — the
|
|
170
|
+
# timing line lands in a log file, never here.
|
|
171
|
+
client = _FakeClient(directives=[], memories=[])
|
|
172
|
+
_ctx, raw = _run_main_with(client)
|
|
173
|
+
self.assertEqual(raw.strip(), "")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class HookTimingLogEmissionTests(unittest.TestCase):
|
|
177
|
+
"""_emit_hook_timing_log must reproduce bin/run-hook.sh's line schema,
|
|
178
|
+
weekday ring, and env-knob behaviour — the hook-timings row that lets this
|
|
179
|
+
UserPromptSubmit hook show up alongside every wrapped hook."""
|
|
180
|
+
|
|
181
|
+
def setUp(self):
|
|
182
|
+
self._tmpdir = tempfile.mkdtemp(prefix="hook-timings-test-")
|
|
183
|
+
|
|
184
|
+
def tearDown(self):
|
|
185
|
+
import shutil
|
|
186
|
+
|
|
187
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
188
|
+
|
|
189
|
+
def _logfile(self):
|
|
190
|
+
dow = time.strftime("%a", time.localtime())
|
|
191
|
+
return os.path.join(self._tmpdir, f"hook-timings-{dow}.log")
|
|
192
|
+
|
|
193
|
+
def test_emits_line_with_run_hook_schema(self):
|
|
194
|
+
with patch.dict(os.environ, {"SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir}):
|
|
195
|
+
recall._emit_hook_timing_log(142, 0)
|
|
196
|
+
path = self._logfile()
|
|
197
|
+
self.assertTrue(os.path.isfile(path))
|
|
198
|
+
with open(path, encoding="utf-8") as f:
|
|
199
|
+
lines = f.read().splitlines()
|
|
200
|
+
self.assertEqual(len(lines), 1)
|
|
201
|
+
row = json.loads(lines[0])
|
|
202
|
+
# Exact key set + types run-hook.sh writes.
|
|
203
|
+
self.assertEqual(
|
|
204
|
+
set(row.keys()), {"ts", "date", "source", "code", "duration_ms", "status"}
|
|
205
|
+
)
|
|
206
|
+
self.assertEqual(row["source"], "hook:hindsight-recall")
|
|
207
|
+
self.assertEqual(row["code"], "recall.py")
|
|
208
|
+
self.assertEqual(row["duration_ms"], 142)
|
|
209
|
+
self.assertEqual(row["status"], 0)
|
|
210
|
+
self.assertEqual(row["date"], time.strftime("%Y-%m-%d", time.localtime()))
|
|
211
|
+
|
|
212
|
+
def test_falls_back_to_telegram_state_dir(self):
|
|
213
|
+
with patch.dict(
|
|
214
|
+
os.environ,
|
|
215
|
+
{"TELEGRAM_STATE_DIR": self._tmpdir},
|
|
216
|
+
clear=False,
|
|
217
|
+
), patch.dict(os.environ, {}, clear=False):
|
|
218
|
+
os.environ.pop("SWITCHROOM_HOOK_TIMING_DIR", None)
|
|
219
|
+
recall._emit_hook_timing_log(5, 0)
|
|
220
|
+
self.assertTrue(os.path.isfile(self._logfile()))
|
|
221
|
+
|
|
222
|
+
def test_disabled_by_env(self):
|
|
223
|
+
with patch.dict(
|
|
224
|
+
os.environ,
|
|
225
|
+
{
|
|
226
|
+
"SWITCHROOM_HOOK_TIMING": "0",
|
|
227
|
+
"SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir,
|
|
228
|
+
},
|
|
229
|
+
):
|
|
230
|
+
recall._emit_hook_timing_log(999, 0)
|
|
231
|
+
self.assertFalse(os.path.isfile(self._logfile()))
|
|
232
|
+
|
|
233
|
+
def test_min_ms_filters_fast_invocations(self):
|
|
234
|
+
with patch.dict(
|
|
235
|
+
os.environ,
|
|
236
|
+
{
|
|
237
|
+
"SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir,
|
|
238
|
+
"SWITCHROOM_HOOK_TIMING_MIN_MS": "100",
|
|
239
|
+
},
|
|
240
|
+
):
|
|
241
|
+
recall._emit_hook_timing_log(50, 0) # below floor → dropped
|
|
242
|
+
recall._emit_hook_timing_log(150, 0) # at/above → kept
|
|
243
|
+
with open(self._logfile(), encoding="utf-8") as f:
|
|
244
|
+
rows = [json.loads(x) for x in f if x.strip()]
|
|
245
|
+
self.assertEqual([r["duration_ms"] for r in rows], [150])
|
|
246
|
+
|
|
247
|
+
def test_no_dir_is_silent_noop(self):
|
|
248
|
+
# No timing dir and no TELEGRAM_STATE_DIR → nothing written, no raise.
|
|
249
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
250
|
+
os.environ.pop("SWITCHROOM_HOOK_TIMING_DIR", None)
|
|
251
|
+
os.environ.pop("TELEGRAM_STATE_DIR", None)
|
|
252
|
+
recall._emit_hook_timing_log(10, 0) # must not raise
|
|
253
|
+
self.assertFalse(os.path.isfile(self._logfile()))
|
|
254
|
+
|
|
255
|
+
def test_stale_weekday_file_is_reset(self):
|
|
256
|
+
path = self._logfile()
|
|
257
|
+
# Seed the ring file with a line dated a week ago (different date, same
|
|
258
|
+
# weekday) — the emitter must truncate it before appending today's row.
|
|
259
|
+
stale = (
|
|
260
|
+
'{"ts":"2000-01-01T00:00:00+0000","date":"2000-01-01",'
|
|
261
|
+
'"source":"hook:hindsight-recall","code":"recall.py",'
|
|
262
|
+
'"duration_ms":1,"status":0}\n'
|
|
263
|
+
)
|
|
264
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
265
|
+
f.write(stale)
|
|
266
|
+
with patch.dict(os.environ, {"SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir}):
|
|
267
|
+
recall._emit_hook_timing_log(7, 0)
|
|
268
|
+
with open(path, encoding="utf-8") as f:
|
|
269
|
+
rows = [json.loads(x) for x in f if x.strip()]
|
|
270
|
+
# Stale line gone; exactly today's row remains.
|
|
271
|
+
self.assertEqual(len(rows), 1)
|
|
272
|
+
self.assertEqual(rows[0]["duration_ms"], 7)
|
|
273
|
+
self.assertEqual(rows[0]["date"], time.strftime("%Y-%m-%d", time.localtime()))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
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()
|