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.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +163 -31
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +9 -4
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
- package/vendor/hindsight-memory/scripts/recall.py +144 -11
- package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
- package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
|
@@ -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()
|