switchroom 0.21.16 → 0.21.17

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.
@@ -0,0 +1,283 @@
1
+ """Memory v2 M5 — Surface B: orientation-at-boot (hook wiring).
2
+
3
+ End-to-end tests for orientation.py's run() — the SessionStart hook entry.
4
+ Every assertion is an OUTCOME (what reached stdout / whether a refresh was
5
+ requested / that boot was never blocked), not a call-path spy, so reverting the
6
+ fix it guards turns the test RED (carve-M5 §8 tautology-guard discipline).
7
+
8
+ Coverage:
9
+ T2 DARK by default: memoryOrientationEnabled off is a HARD no-op — no
10
+ stdout, no bank resolve, no network call (the red-team kill switch).
11
+ T2 Every failure path (server unreachable, no model, read error, empty
12
+ payload, degraded/undated model) degrades to a VISIBLE cold notice AND
13
+ enqueues a refresh AND never raises — boot is never blocked.
14
+ T3 A stale (1.5x-3x) model is injected WITH a visible prefix; a fresh model
15
+ is injected plainly; a degraded (>3x) model is NOT injected.
16
+ T4 Matcher-less re-fire: a "compact" SessionStart produces the SAME output
17
+ as "startup" (the free post-compaction re-seat — E-88).
18
+ main() always exits 0 (a non-zero SessionStart hook BLOCKS the turn).
19
+
20
+ Stdlib-only.
21
+ """
22
+
23
+ import io
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import unittest
29
+ from contextlib import redirect_stdout
30
+ from datetime import datetime, timedelta, timezone
31
+ from unittest import mock
32
+
33
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
34
+ if SCRIPTS_DIR not in sys.path:
35
+ sys.path.insert(0, SCRIPTS_DIR)
36
+
37
+ import orientation # noqa: E402
38
+
39
+
40
+ def _iso_hours_ago(hours):
41
+ return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
42
+
43
+
44
+ class _FakeClient:
45
+ """Stand-in for HindsightClient. Records the OUTCOME the hook depends on:
46
+ which bank was read, and returns scripted list/get responses (or raises)."""
47
+
48
+ def __init__(self, models=None, model_full=None, list_raises=None, get_raises=None):
49
+ self._models = models if models is not None else {"items": []}
50
+ self._full = model_full
51
+ self._list_raises = list_raises
52
+ self._get_raises = get_raises
53
+ self.listed_bank = None
54
+ self.got = None
55
+
56
+ def list_mental_models(self, bank_id, timeout=5):
57
+ self.listed_bank = bank_id
58
+ if self._list_raises:
59
+ raise self._list_raises
60
+ return self._models
61
+
62
+ def get_mental_model(self, bank_id, model_id, detail="full", timeout=5):
63
+ self.got = (bank_id, model_id)
64
+ if self._get_raises:
65
+ raise self._get_raises
66
+ return self._full
67
+
68
+
69
+ def _run(config, hook_input=None, client=None, api_raises=None):
70
+ """Drive orientation.run() with patched I/O; return (stdout_str, client)."""
71
+ hook_input = hook_input or {"source": "startup"}
72
+ buf = io.StringIO()
73
+ with mock.patch.object(orientation, "derive_bank_id", return_value="klanker"), \
74
+ mock.patch.object(orientation, "HindsightClient", return_value=client):
75
+ if api_raises is not None:
76
+ api_ctx = mock.patch.object(orientation, "get_api_url", side_effect=api_raises)
77
+ else:
78
+ api_ctx = mock.patch.object(
79
+ orientation, "get_api_url", return_value="http://127.0.0.1:18888"
80
+ )
81
+ with api_ctx, redirect_stdout(buf):
82
+ orientation.run(hook_input, config)
83
+ return buf.getvalue(), client
84
+
85
+
86
+ def _emitted_context(stdout_str):
87
+ """Parse the additionalContext out of the hook's stdout envelope, or None."""
88
+ stdout_str = stdout_str.strip()
89
+ if not stdout_str:
90
+ return None
91
+ payload = json.loads(stdout_str)
92
+ return payload["hookSpecificOutput"]["additionalContext"]
93
+
94
+
95
+ class _RefreshSpyMixin:
96
+ """Point HINDSIGHT_STATE_DIR at a tmpdir so enqueue_refresh's JSONL marker is
97
+ inspectable — the OUTCOME 'a refresh was requested' without a live scheduler."""
98
+
99
+ def setUp(self):
100
+ self._tmp = tempfile.mkdtemp()
101
+ self._env = mock.patch.dict(os.environ, {"HINDSIGHT_STATE_DIR": self._tmp})
102
+ self._env.start()
103
+
104
+ def tearDown(self):
105
+ self._env.stop()
106
+ import shutil
107
+ shutil.rmtree(self._tmp, ignore_errors=True)
108
+
109
+ def _refresh_requested(self):
110
+ marker = os.path.join(self._tmp, "orientation-refresh-pending.jsonl")
111
+ if not os.path.exists(marker):
112
+ return False
113
+ with open(marker) as f:
114
+ return any(line.strip() for line in f)
115
+
116
+
117
+ ENABLED = {"memoryOrientationEnabled": True, "memoryOrientationCadenceHours": 48}
118
+
119
+
120
+ class TestDarkByDefault(_RefreshSpyMixin, unittest.TestCase):
121
+ """T2 — the kill switch. Off (the default) is a hard, silent no-op."""
122
+
123
+ def test_disabled_emits_nothing_and_never_touches_client(self):
124
+ client = _FakeClient()
125
+ # A client whose methods would blow up if called — proves no I/O happens.
126
+ client.list_mental_models = mock.Mock(side_effect=AssertionError("must not read"))
127
+ stdout, _ = _run({"memoryOrientationEnabled": False}, client=client)
128
+ self.assertEqual(stdout.strip(), "")
129
+ self.assertFalse(self._refresh_requested())
130
+
131
+ def test_absent_flag_defaults_to_off(self):
132
+ # A stripped/absent value must fail to OFF (fail-safe).
133
+ client = _FakeClient()
134
+ client.list_mental_models = mock.Mock(side_effect=AssertionError("must not read"))
135
+ stdout, _ = _run({}, client=client)
136
+ self.assertEqual(stdout.strip(), "")
137
+
138
+
139
+ class TestColdPaths(_RefreshSpyMixin, unittest.TestCase):
140
+ """T2 — every failure degrades to a visible cold notice + refresh, never a block."""
141
+
142
+ def _assert_cold(self, stdout):
143
+ ctx = _emitted_context(stdout)
144
+ self.assertIsNotNone(ctx)
145
+ self.assertIn("not yet built or refreshed", ctx)
146
+ self.assertTrue(self._refresh_requested())
147
+
148
+ def test_server_unreachable(self):
149
+ stdout, _ = _run(ENABLED, client=None, api_raises=RuntimeError("no daemon"))
150
+ self._assert_cold(stdout)
151
+
152
+ def test_no_model_named_orientation(self):
153
+ client = _FakeClient(models={"items": [{"id": "x", "name": "something-else"}]})
154
+ stdout, _ = _run(ENABLED, client=client)
155
+ self._assert_cold(stdout)
156
+
157
+ def test_list_models_raises(self):
158
+ client = _FakeClient(list_raises=TimeoutError("read timed out"))
159
+ stdout, _ = _run(ENABLED, client=client)
160
+ self._assert_cold(stdout)
161
+
162
+ def test_model_read_raises(self):
163
+ client = _FakeClient(
164
+ models={"items": [{"id": "m1", "name": "orientation"}]},
165
+ get_raises=TimeoutError("read timed out"),
166
+ )
167
+ stdout, _ = _run(ENABLED, client=client)
168
+ self._assert_cold(stdout)
169
+
170
+ def test_empty_content(self):
171
+ client = _FakeClient(
172
+ models={"items": [{"id": "m1", "name": "orientation"}]},
173
+ model_full={"content": " ", "last_refreshed_at": _iso_hours_ago(1)},
174
+ )
175
+ stdout, _ = _run(ENABLED, client=client)
176
+ self._assert_cold(stdout)
177
+
178
+ def test_degraded_model_not_injected(self):
179
+ # Refreshed 200h ago at cadence 48 -> >3x -> degraded -> cold, never
180
+ # presented stale-as-fresh.
181
+ client = _FakeClient(
182
+ models={"items": [{"id": "m1", "name": "orientation"}]},
183
+ model_full={"content": "# Real\nbody", "last_refreshed_at": _iso_hours_ago(200)},
184
+ )
185
+ stdout, _ = _run(ENABLED, client=client)
186
+ self._assert_cold(stdout)
187
+
188
+ def test_undated_model_not_injected(self):
189
+ client = _FakeClient(
190
+ models={"items": [{"id": "m1", "name": "orientation"}]},
191
+ model_full={"content": "# Real\nbody", "last_refreshed_at": None},
192
+ )
193
+ stdout, _ = _run(ENABLED, client=client)
194
+ self._assert_cold(stdout)
195
+
196
+
197
+ class TestInjectPaths(_RefreshSpyMixin, unittest.TestCase):
198
+ """T3 — usable models are injected; fresh plainly, stale with a prefix."""
199
+
200
+ def test_fresh_model_injected_plainly(self):
201
+ client = _FakeClient(
202
+ models={"items": [{"id": "m1", "name": "orientation"}]},
203
+ model_full={"content": "# Brief\nthe orientation body",
204
+ "last_refreshed_at": _iso_hours_ago(2)},
205
+ )
206
+ stdout, _ = _run(ENABLED, client=client)
207
+ ctx = _emitted_context(stdout)
208
+ self.assertIn("the orientation body", ctx)
209
+ self.assertNotIn("may be stale", ctx)
210
+ # A usable inject does NOT request a refresh.
211
+ self.assertFalse(self._refresh_requested())
212
+ # It read the agent's OWN bank.
213
+ self.assertEqual(client.listed_bank, "klanker")
214
+
215
+ def test_stale_model_injected_with_prefix(self):
216
+ # 100h ago at cadence 48 -> 1.5x(72) < 100 < 3x(144) -> stale.
217
+ client = _FakeClient(
218
+ models={"items": [{"id": "m1", "name": "orientation"}]},
219
+ model_full={"content": "# Brief\nthe orientation body",
220
+ "last_refreshed_at": _iso_hours_ago(100)},
221
+ )
222
+ stdout, _ = _run(ENABLED, client=client)
223
+ ctx = _emitted_context(stdout)
224
+ self.assertIn("the orientation body", ctx)
225
+ self.assertIn("may be stale", ctx)
226
+
227
+
228
+ class TestCompactionReSeat(_RefreshSpyMixin, unittest.TestCase):
229
+ """T4 — matcher-less: a compaction SessionStart re-seats identically to startup."""
230
+
231
+ def _model(self):
232
+ return {"items": [{"id": "m1", "name": "orientation"}]}, {
233
+ "content": "# Brief\nbody text", "last_refreshed_at": _iso_hours_ago(2)
234
+ }
235
+
236
+ def test_compact_source_matches_startup_source(self):
237
+ models, full = self._model()
238
+ out_start, _ = _run(ENABLED, hook_input={"source": "startup"},
239
+ client=_FakeClient(models=models, model_full=full))
240
+ out_compact, _ = _run(ENABLED, hook_input={"source": "compact"},
241
+ client=_FakeClient(models=models, model_full=full))
242
+ self.assertEqual(_emitted_context(out_start), _emitted_context(out_compact))
243
+ self.assertIn("body text", _emitted_context(out_compact))
244
+
245
+
246
+ class TestNeverBlocksBoot(unittest.TestCase):
247
+ """The script must exit 0 on every path — a non-zero SessionStart hook BLOCKS
248
+ the turn in Claude Code. Driven as a real subprocess (the actual __main__
249
+ guard), because that guard is exactly what turns an internal raise into a
250
+ clean exit; an in-process patch of sys.exit could never catch a regression
251
+ where the guard itself is removed."""
252
+
253
+ def _run_script(self, env_extra, stdin_text="{}"):
254
+ import subprocess
255
+ env = dict(os.environ)
256
+ env.update(env_extra)
257
+ script = os.path.join(SCRIPTS_DIR, "orientation.py")
258
+ proc = subprocess.run(
259
+ [sys.executable, script],
260
+ input=stdin_text, capture_output=True, text=True, env=env, timeout=20,
261
+ )
262
+ return proc
263
+
264
+ def test_disabled_exits_zero_silent(self):
265
+ proc = self._run_script({"HINDSIGHT_ORIENTATION_ENABLED": "false"})
266
+ self.assertEqual(proc.returncode, 0)
267
+ self.assertEqual(proc.stdout.strip(), "")
268
+
269
+ def test_enabled_but_server_unreachable_exits_zero_with_cold_notice(self):
270
+ # Point at a closed port: the read fails, but boot must NOT block — exit
271
+ # 0 and a visible cold notice on stdout, never a hang or non-zero.
272
+ with tempfile.TemporaryDirectory() as tmp:
273
+ proc = self._run_script({
274
+ "HINDSIGHT_ORIENTATION_ENABLED": "true",
275
+ "HINDSIGHT_API_URL": "http://127.0.0.1:9", # discard port, refused
276
+ "HINDSIGHT_STATE_DIR": tmp,
277
+ })
278
+ self.assertEqual(proc.returncode, 0, proc.stderr)
279
+ self.assertIn("not yet built or refreshed", proc.stdout)
280
+
281
+
282
+ if __name__ == "__main__":
283
+ unittest.main()
@@ -0,0 +1,176 @@
1
+ """Memory v2 M5 — Surface B: orientation-at-boot (pure logic).
2
+
3
+ Unit tests for lib/orientation.py — the network-free half of the orientation
4
+ SessionStart hook. These pin the DETERMINISTIC mechanism (carve-M5 §0c/§5/§8):
5
+ every branch that decides inject / prefix-as-stale / degrade-to-cold / truncate
6
+ is asserted by OUTCOME, so reverting the corresponding rule turns a test RED.
7
+
8
+ Coverage:
9
+ T1 render stays within the 2048-token TOTAL cap, even with a stale prefix,
10
+ and emits a VISIBLE truncation marker when content is dropped.
11
+ T3 staleness is PER-TIER (1.5×/3× of the resolved cadence), not a fixed 36h —
12
+ parametrized over cadence 24 AND 48 so a hardcoded threshold fails.
13
+ Truncation is rule-aware (whole markdown sections; hard-cut only when the
14
+ first section alone overflows) and never exceeds the budget.
15
+ Cold notice + stale prefix are visible one-liners (never stale-as-fresh).
16
+
17
+ Stdlib-only.
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import unittest
23
+ from datetime import datetime, timedelta, timezone
24
+
25
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
26
+ if SCRIPTS_DIR not in sys.path:
27
+ sys.path.insert(0, SCRIPTS_DIR)
28
+
29
+ from lib import orientation as orient # noqa: E402
30
+
31
+
32
+ class TestEstimateTokens(unittest.TestCase):
33
+ def test_empty_is_zero(self):
34
+ self.assertEqual(orient.estimate_tokens(""), 0)
35
+
36
+ def test_ceils_chars_over_four(self):
37
+ # 7 chars / 4 = 1.75 -> ceil 2. A floor would under-count and let the
38
+ # budget math overflow the cap.
39
+ self.assertEqual(orient.estimate_tokens("abcdefg"), 2)
40
+
41
+
42
+ class TestClassifyStalenessPerTier(unittest.TestCase):
43
+ """T3 — thresholds must scale with the agent's cadence tier, not a fixed 36h."""
44
+
45
+ NOW = datetime(2026, 8, 17, 12, 0, 0, tzinfo=timezone.utc)
46
+
47
+ def _at(self, hours_ago):
48
+ return (self.NOW - timedelta(hours=hours_ago)).isoformat()
49
+
50
+ def test_fresh_stale_degraded_boundaries_cadence_24(self):
51
+ # cadence 24: stale at 36h (1.5x), degrade at 72h (3x).
52
+ for hours, expected in [(0, "fresh"), (35, "fresh"), (36, "stale"),
53
+ (71, "stale"), (72, "degraded"), (999, "degraded")]:
54
+ state, _ = orient.classify_staleness(self._at(hours), 24, now=self.NOW)
55
+ self.assertEqual(state, expected, f"cadence24 @ {hours}h")
56
+
57
+ def test_fresh_stale_degraded_boundaries_cadence_48(self):
58
+ # cadence 48: stale at 72h (1.5x), degrade at 144h (3x). A FIXED 36h
59
+ # threshold would wrongly call 40h "stale" here — this is the R4 guard.
60
+ for hours, expected in [(0, "fresh"), (40, "fresh"), (71, "fresh"),
61
+ (72, "stale"), (143, "stale"), (144, "degraded")]:
62
+ state, _ = orient.classify_staleness(self._at(hours), 48, now=self.NOW)
63
+ self.assertEqual(state, expected, f"cadence48 @ {hours}h")
64
+
65
+ def test_a_48h_tier_model_at_40h_is_NOT_stale(self):
66
+ # Direct restatement of red-team R4: the staleness guard biting a day
67
+ # early. 40h < 1.5*48 = 72h, so it is fresh.
68
+ state, hours_ago = orient.classify_staleness(self._at(40), 48, now=self.NOW)
69
+ self.assertEqual(state, "fresh")
70
+ self.assertAlmostEqual(hours_ago, 40.0, places=1)
71
+
72
+ def test_missing_timestamp_is_unknown(self):
73
+ state, hours_ago = orient.classify_staleness(None, 48, now=self.NOW)
74
+ self.assertEqual(state, "unknown")
75
+ self.assertIsNone(hours_ago)
76
+
77
+ def test_unparseable_timestamp_is_unknown(self):
78
+ state, hours_ago = orient.classify_staleness("not-a-date", 48, now=self.NOW)
79
+ self.assertEqual(state, "unknown")
80
+ self.assertIsNone(hours_ago)
81
+
82
+ def test_trailing_Z_is_parsed(self):
83
+ state, _ = orient.classify_staleness(
84
+ self._at(1).replace("+00:00", "Z"), 48, now=self.NOW
85
+ )
86
+ self.assertEqual(state, "fresh")
87
+
88
+ def test_future_timestamp_clock_skew_is_fresh_not_negative(self):
89
+ future = (self.NOW + timedelta(hours=5)).isoformat()
90
+ state, hours_ago = orient.classify_staleness(future, 48, now=self.NOW)
91
+ self.assertEqual(state, "fresh")
92
+ self.assertEqual(hours_ago, 0.0)
93
+
94
+
95
+ class TestTruncateToBudget(unittest.TestCase):
96
+ def test_under_budget_is_untouched(self):
97
+ content = "# A\nshort body"
98
+ body, truncated = orient.truncate_to_budget(content, 1000)
99
+ self.assertEqual(body, content)
100
+ self.assertFalse(truncated)
101
+
102
+ def test_empty_returns_empty_not_truncated(self):
103
+ self.assertEqual(orient.truncate_to_budget(" ", 1000), ("", False))
104
+
105
+ def test_drops_whole_trailing_sections(self):
106
+ # Two ~120-token sections, budget only fits one. The kept text must be a
107
+ # WHOLE section (starts with its header), and truncated=True.
108
+ sec_a = "# Keep\n" + ("alpha " * 120)
109
+ sec_b = "# Drop\n" + ("bravo " * 120)
110
+ body, truncated = orient.truncate_to_budget(sec_a + "\n" + sec_b, 150)
111
+ self.assertTrue(truncated)
112
+ self.assertIn("# Keep", body)
113
+ self.assertNotIn("# Drop", body)
114
+ self.assertLessEqual(orient.estimate_tokens(body), 150)
115
+
116
+ def test_first_section_overflow_hard_cuts_on_word_boundary(self):
117
+ # A single header-less blob bigger than budget must still yield content
118
+ # (never empty), cut at whitespace (no mid-word slice), within budget.
119
+ blob = "wordword " * 400
120
+ body, truncated = orient.truncate_to_budget(blob, 50)
121
+ self.assertTrue(truncated)
122
+ self.assertTrue(body)
123
+ self.assertLessEqual(orient.estimate_tokens(body), 50)
124
+ self.assertFalse(body.endswith("wordwor")) # not sliced mid-word
125
+
126
+
127
+ class TestRenderOrientation(unittest.TestCase):
128
+ """T1 — rendered additionalContext obeys the 2048 TOTAL cap and is visible."""
129
+
130
+ def _big_content(self):
131
+ # ~2,400 tokens of content — larger than the 1800 content budget AND the
132
+ # 2048 total cap, forcing truncation.
133
+ return "# Section\n" + ("token " * 2400)
134
+
135
+ def test_fresh_render_within_total_cap(self):
136
+ rendered = orient.render_orientation(self._big_content(), "fresh", 3.0)
137
+ self.assertLessEqual(
138
+ orient.estimate_tokens(rendered), orient.ORIENTATION_TOTAL_TOKEN_CAP
139
+ )
140
+ self.assertIn(orient.TRUNCATION_MARKER, rendered)
141
+ self.assertIn('<orientation source="memory">', rendered)
142
+ self.assertIn("</orientation>", rendered)
143
+
144
+ def test_stale_render_includes_prefix_and_still_within_cap(self):
145
+ rendered = orient.render_orientation(self._big_content(), "stale", 90.0)
146
+ self.assertLessEqual(
147
+ orient.estimate_tokens(rendered), orient.ORIENTATION_TOTAL_TOKEN_CAP
148
+ )
149
+ # The stale prefix must be present AND the cap still held — the prefix
150
+ # eats into the content budget, it does not blow the ceiling.
151
+ self.assertIn("may be stale", rendered)
152
+ self.assertIn("90h ago", rendered)
153
+
154
+ def test_fresh_render_has_no_stale_prefix(self):
155
+ rendered = orient.render_orientation("# S\nsmall body", "fresh", 1.0)
156
+ self.assertNotIn("may be stale", rendered)
157
+
158
+ def test_small_fresh_content_not_marked_truncated(self):
159
+ rendered = orient.render_orientation("# S\nsmall body", "fresh", 1.0)
160
+ self.assertNotIn(orient.TRUNCATION_MARKER, rendered)
161
+ self.assertIn("small body", rendered)
162
+
163
+
164
+ class TestNotices(unittest.TestCase):
165
+ def test_cold_notice_is_framed_and_mentions_refresh(self):
166
+ notice = orient.cold_notice()
167
+ self.assertIn('<orientation source="memory">', notice)
168
+ self.assertIn("</orientation>", notice)
169
+ self.assertIn("refresh", notice.lower())
170
+
171
+ def test_stale_prefix_unknown_hours(self):
172
+ self.assertIn("unknown", orient.stale_prefix(None))
173
+
174
+
175
+ if __name__ == "__main__":
176
+ unittest.main()