switchroom 0.21.16 → 0.21.18
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 +1492 -1136
- 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/telegram-plugin/scripts/bun-test-ci.sh +6 -0
- package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
- package/telegram-plugin/uat/flip/allowlist.ts +349 -0
- package/telegram-plugin/uat/flip/gate.test.ts +153 -0
- package/telegram-plugin/uat/flip/gate.ts +232 -0
- package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
- package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
- package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
- package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
- package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
- package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
- package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
- package/telegram-plugin/uat/flip/recall-log.ts +178 -0
- package/telegram-plugin/uat/flip/report.ts +95 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
- package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
- package/telegram-plugin/uat/runners/scorer.ts +1 -1
- 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,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()
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""M4 prefetch-buffer MUTATION INVALIDATION — red-team-M3 R2 (BLOCKER).
|
|
2
|
+
|
|
3
|
+
The M4 prefetch buffer holds a RECALLED memories block captured at a prior
|
|
4
|
+
turn's Stop hook and consumed at the next UserPromptSubmit. If a mutation —
|
|
5
|
+
a rule/directive retire, or a retain that supersedes a fact — lands between
|
|
6
|
+
producing that buffer and consuming it, the pre-mutation snapshot can
|
|
7
|
+
resurrect the just-retired/just-changed content on a later turn. Worse,
|
|
8
|
+
``prefetch.run_prefetch`` returns early WITHOUT overwriting the buffer when
|
|
9
|
+
this turn's fresh recall fails or returns empty, so a stale buffer can
|
|
10
|
+
persist and be re-served for turns after the mutation.
|
|
11
|
+
|
|
12
|
+
These are OUTCOME tests: they assert on the rendered ``additionalContext``
|
|
13
|
+
that the consumer emits (the real injection surface), and each would FAIL if
|
|
14
|
+
the invalidation regressed:
|
|
15
|
+
|
|
16
|
+
* ``RetireDoesNotResurrectTests`` — a directive-write turn invalidates the
|
|
17
|
+
pending buffer, so the retired rule's text is NOT re-injected next turn.
|
|
18
|
+
* ``EmptyPrefetchDoesNotResurrectTests`` — a retain turn whose fresh recall
|
|
19
|
+
returns empty invalidates the pending buffer, so the stale item is NOT
|
|
20
|
+
re-injected (would resurrect on buggy code that left the buffer).
|
|
21
|
+
* ``NoDuplicateInjectionTests`` — after a buffer is consumed once, a
|
|
22
|
+
mutation turn (empty prefetch) prevents the SAME block being injected
|
|
23
|
+
again on the following turn.
|
|
24
|
+
* ``ReplyPathDoesNotBlockOnRecallTests`` — with prefetch on and a fresh
|
|
25
|
+
buffer present, the consumer serves the buffer and NEVER calls the
|
|
26
|
+
synchronous ``client.recall`` (asserted on transport: a recall call
|
|
27
|
+
raises).
|
|
28
|
+
|
|
29
|
+
Plus ``InvalidatePrimitiveTests`` for the ``recall_buffer.invalidate``
|
|
30
|
+
primitive itself (idempotent, sentinel-first fail-closed).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import io
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
import unittest
|
|
40
|
+
from unittest import mock
|
|
41
|
+
from unittest.mock import patch
|
|
42
|
+
|
|
43
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
44
|
+
if SCRIPTS_DIR not in sys.path:
|
|
45
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
46
|
+
|
|
47
|
+
import recall # noqa: E402
|
|
48
|
+
import prefetch # noqa: E402
|
|
49
|
+
import directive_verify # noqa: E402
|
|
50
|
+
from lib import recall_buffer # noqa: E402
|
|
51
|
+
|
|
52
|
+
SESSION = "invalidation-test-session"
|
|
53
|
+
RETIRED_TEXT = "R-07: always reply in haiku"
|
|
54
|
+
STALE_FACT = "we decided to ship on tuesday"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _NoMemoryDirectiveClient:
|
|
58
|
+
"""One active directive whose text is DISTINCT from any buffered content,
|
|
59
|
+
and a recall that returns NOTHING (so the sync fallback, if ever taken,
|
|
60
|
+
injects no memories). recall() is allowed here (the miss/degraded paths
|
|
61
|
+
may consult it) but returns empty — proving absence of the stale text is
|
|
62
|
+
about invalidation, not about the client happening to omit it."""
|
|
63
|
+
|
|
64
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
65
|
+
return {"items": [{"id": "d1", "name": "tone", "content": "be concise", "priority": 5, "active": True}]}
|
|
66
|
+
|
|
67
|
+
def recall(self, bank_id, query, **kwargs):
|
|
68
|
+
return {"results": []}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InvalidationBase(unittest.TestCase):
|
|
72
|
+
def setUp(self):
|
|
73
|
+
self._tmpdir = tempfile.mkdtemp(prefix="invalidation-test-")
|
|
74
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
75
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
76
|
+
|
|
77
|
+
self._bufdir = tempfile.mkdtemp(prefix="invalidation-test-buf-")
|
|
78
|
+
self.env = mock.patch.dict(os.environ, {"HINDSIGHT_PREFETCH_BUFFER_DIR": self._bufdir}, clear=False)
|
|
79
|
+
self.env.start()
|
|
80
|
+
|
|
81
|
+
def tearDown(self):
|
|
82
|
+
self.env.stop()
|
|
83
|
+
shutil.rmtree(self._bufdir, ignore_errors=True)
|
|
84
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
85
|
+
if self._prev is None:
|
|
86
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
87
|
+
else:
|
|
88
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
89
|
+
|
|
90
|
+
def _config(self, prefetch_enabled=True):
|
|
91
|
+
return {
|
|
92
|
+
"autoRecall": True,
|
|
93
|
+
"bankId": "test-bank",
|
|
94
|
+
"recallMaxTokens": 4096,
|
|
95
|
+
"recallBudget": "mid",
|
|
96
|
+
"recallContextTurns": 1,
|
|
97
|
+
"recallMaxQueryChars": 800,
|
|
98
|
+
"recallPromptPreamble": "",
|
|
99
|
+
"recallParallelDeadlineSeconds": 5,
|
|
100
|
+
"directivesCacheTtlSeconds": 0,
|
|
101
|
+
"memoryPrefetchEnabled": prefetch_enabled,
|
|
102
|
+
"memoryPrefetchPollCapMs": 100,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def _consume(self, config, client, prompt="what did we decide"):
|
|
106
|
+
"""Drive the recall.py consumer (UserPromptSubmit) once; return the
|
|
107
|
+
emitted additionalContext string (or "" if nothing emitted)."""
|
|
108
|
+
hook_input = {"prompt": prompt, "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
|
|
109
|
+
stdout = io.StringIO()
|
|
110
|
+
with patch("recall.load_config", return_value=config), \
|
|
111
|
+
patch("recall.get_api_url", return_value="http://fake"), \
|
|
112
|
+
patch("recall.HindsightClient", return_value=client), \
|
|
113
|
+
patch("recall.ensure_bank_mission"), \
|
|
114
|
+
patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
|
|
115
|
+
patch("sys.stdout", stdout):
|
|
116
|
+
recall.main()
|
|
117
|
+
out = stdout.getvalue()
|
|
118
|
+
if not out:
|
|
119
|
+
return ""
|
|
120
|
+
return json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class InvalidatePrimitiveTests(InvalidationBase):
|
|
124
|
+
def test_invalidate_removes_buffer_and_sentinel(self):
|
|
125
|
+
recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
|
|
126
|
+
recall_buffer.write_sentinel(SESSION)
|
|
127
|
+
self.assertTrue(os.path.isfile(recall_buffer._buffer_path(SESSION)))
|
|
128
|
+
self.assertTrue(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
|
|
129
|
+
|
|
130
|
+
recall_buffer.invalidate(SESSION)
|
|
131
|
+
|
|
132
|
+
self.assertFalse(os.path.isfile(recall_buffer._buffer_path(SESSION)))
|
|
133
|
+
self.assertFalse(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
|
|
134
|
+
# read_if_fresh must now report nothing fresh (fail-closed).
|
|
135
|
+
payload, _ = recall_buffer.read_if_fresh(SESSION, last_consumed_token=None)
|
|
136
|
+
self.assertIsNone(payload)
|
|
137
|
+
|
|
138
|
+
def test_invalidate_is_idempotent_on_absent_files(self):
|
|
139
|
+
# No buffer written — must not raise.
|
|
140
|
+
recall_buffer.invalidate(SESSION)
|
|
141
|
+
recall_buffer.invalidate(SESSION)
|
|
142
|
+
|
|
143
|
+
def test_orphaned_payload_after_sentinel_first_delete_fails_closed(self):
|
|
144
|
+
# Simulate a partial invalidation where only the sentinel unlink
|
|
145
|
+
# landed (sentinel is deleted FIRST by design): an orphaned payload
|
|
146
|
+
# must never read as fresh.
|
|
147
|
+
recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
|
|
148
|
+
recall_buffer.write_sentinel(SESSION)
|
|
149
|
+
os.remove(recall_buffer._sentinel_path(SESSION))
|
|
150
|
+
payload, _ = recall_buffer.read_if_fresh(SESSION, last_consumed_token=None)
|
|
151
|
+
self.assertIsNone(payload)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class RetireDoesNotResurrectTests(InvalidationBase):
|
|
155
|
+
"""Assertion (1): a directive retire invalidates the pending buffer so the
|
|
156
|
+
retired item is NOT re-injected on the next turn."""
|
|
157
|
+
|
|
158
|
+
def _retire_turn_messages(self):
|
|
159
|
+
# A real turn transcript: the user retires a rule, the assistant issues
|
|
160
|
+
# a delete_directive tool_use. This is what directive_verify inspects.
|
|
161
|
+
return [
|
|
162
|
+
{"role": "user", "content": "Retire the haiku rule R-07, it no longer applies."},
|
|
163
|
+
{
|
|
164
|
+
"role": "assistant",
|
|
165
|
+
"content": [
|
|
166
|
+
{"type": "tool_use", "id": "t1", "name": "delete_directive", "input": {"id": "d-r07"}}
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def test_directive_write_turn_invalidates_buffer_and_blocks_resurrection(self):
|
|
172
|
+
config = self._config(prefetch_enabled=True)
|
|
173
|
+
|
|
174
|
+
# Turn N Stop: prefetch buffered the rule text while it was still active.
|
|
175
|
+
recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
|
|
176
|
+
recall_buffer.write_sentinel(SESSION)
|
|
177
|
+
|
|
178
|
+
# Turn N+1: the retire lands. directive_verify's Stop-hook invalidation
|
|
179
|
+
# detects the delete_directive write and drops the pending buffer.
|
|
180
|
+
directive_verify.invalidate_prefetch_buffer_on_directive_write(
|
|
181
|
+
self._retire_turn_messages(), config, SESSION
|
|
182
|
+
)
|
|
183
|
+
self.assertFalse(
|
|
184
|
+
os.path.isfile(recall_buffer._sentinel_path(SESSION)),
|
|
185
|
+
"directive-write turn must invalidate the pending prefetch buffer",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Turn N+2 UserPromptSubmit: the consumer must NOT re-inject the retired
|
|
189
|
+
# rule text. (With no fresh buffer it takes the sync/degraded path, which
|
|
190
|
+
# here surfaces only the fresh directive block, not the retired text.)
|
|
191
|
+
ctx = self._consume(config, _NoMemoryDirectiveClient())
|
|
192
|
+
self.assertNotIn(
|
|
193
|
+
"R-07", ctx,
|
|
194
|
+
"retired rule text must not resurrect from a pre-retire prefetch buffer",
|
|
195
|
+
)
|
|
196
|
+
self.assertNotIn("haiku", ctx)
|
|
197
|
+
|
|
198
|
+
def test_non_directive_turn_leaves_buffer_intact(self):
|
|
199
|
+
# Guard against over-invalidation: an ordinary turn (no directive write)
|
|
200
|
+
# must NOT drop the buffer, or every turn would lose the prefetch.
|
|
201
|
+
config = self._config(prefetch_enabled=True)
|
|
202
|
+
recall_buffer.write_buffer(SESSION, "- a normal prefetched memory", {})
|
|
203
|
+
recall_buffer.write_sentinel(SESSION)
|
|
204
|
+
directive_verify.invalidate_prefetch_buffer_on_directive_write(
|
|
205
|
+
[
|
|
206
|
+
{"role": "user", "content": "what's the weather"},
|
|
207
|
+
{"role": "assistant", "content": "Sunny."},
|
|
208
|
+
],
|
|
209
|
+
config,
|
|
210
|
+
SESSION,
|
|
211
|
+
)
|
|
212
|
+
self.assertTrue(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
|
|
213
|
+
|
|
214
|
+
def test_flag_off_is_a_noop(self):
|
|
215
|
+
# Kill switch: with prefetch off, the directive-write invalidation must
|
|
216
|
+
# not touch the buffer module at all.
|
|
217
|
+
config = self._config(prefetch_enabled=False)
|
|
218
|
+
recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
|
|
219
|
+
recall_buffer.write_sentinel(SESSION)
|
|
220
|
+
with patch("directive_verify.recall_buffer.invalidate",
|
|
221
|
+
side_effect=AssertionError("must not invalidate when prefetch is off")):
|
|
222
|
+
directive_verify.invalidate_prefetch_buffer_on_directive_write(
|
|
223
|
+
self._retire_turn_messages(), config, SESSION
|
|
224
|
+
)
|
|
225
|
+
self.assertTrue(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class EmptyPrefetchDoesNotResurrectTests(InvalidationBase):
|
|
229
|
+
"""Assertion (1) via the RETAIN mutation path: a retain turn whose fresh
|
|
230
|
+
recall returns empty must invalidate the pending buffer so the stale item
|
|
231
|
+
is NOT re-served. This FAILS on buggy code that returns early leaving the
|
|
232
|
+
old buffer in place."""
|
|
233
|
+
|
|
234
|
+
def test_retain_turn_with_empty_recall_invalidates_stale_buffer(self):
|
|
235
|
+
config = self._config(prefetch_enabled=True)
|
|
236
|
+
|
|
237
|
+
# Turn N Stop: a stale fact was buffered.
|
|
238
|
+
recall_buffer.write_buffer(SESSION, STALE_FACT, {})
|
|
239
|
+
recall_buffer.write_sentinel(SESSION)
|
|
240
|
+
|
|
241
|
+
# Turn N+1 Stop: prefetch runs (a retain mutation), but this turn's
|
|
242
|
+
# fresh recall returns NOTHING. run_prefetch must have already
|
|
243
|
+
# invalidated the old buffer up front — so no stale buffer survives.
|
|
244
|
+
empty_client = mock.Mock()
|
|
245
|
+
empty_client.recall.return_value = {"results": []}
|
|
246
|
+
hook_input = {
|
|
247
|
+
"prompt": "and another thing",
|
|
248
|
+
"session_id": SESSION,
|
|
249
|
+
"transcript_path": "", # _last_human_prompt degrades to "" -> we inject a query below
|
|
250
|
+
"cwd": "/tmp",
|
|
251
|
+
}
|
|
252
|
+
with patch("prefetch.retain_module.run_retain", return_value={"status": "ok"}), \
|
|
253
|
+
patch("prefetch.HindsightClient", return_value=empty_client), \
|
|
254
|
+
patch("prefetch.get_api_url", return_value="http://fake"), \
|
|
255
|
+
patch("prefetch._last_human_prompt", return_value="and another thing"):
|
|
256
|
+
wrote = prefetch.run_prefetch(hook_input, config)
|
|
257
|
+
|
|
258
|
+
self.assertFalse(wrote, "empty recall must not write a fresh buffer")
|
|
259
|
+
self.assertFalse(
|
|
260
|
+
os.path.isfile(recall_buffer._sentinel_path(SESSION)),
|
|
261
|
+
"a mutation (retain) turn must invalidate the stale buffer even when the fresh recall is empty",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Turn N+2 UserPromptSubmit: the stale fact must NOT re-inject.
|
|
265
|
+
ctx = self._consume(config, _NoMemoryDirectiveClient())
|
|
266
|
+
self.assertNotIn(
|
|
267
|
+
STALE_FACT, ctx,
|
|
268
|
+
"stale buffered fact must not resurrect after a mutation turn with an empty recall",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class NoDuplicateInjectionTests(InvalidationBase):
|
|
273
|
+
"""Assertion (2): the same buffered block is not injected twice. After a
|
|
274
|
+
turn consumes a buffer, a mutation turn (empty prefetch) prevents the SAME
|
|
275
|
+
block from being re-served on the following turn."""
|
|
276
|
+
|
|
277
|
+
def test_consumed_buffer_not_reinjected_after_mutation_turn(self):
|
|
278
|
+
config = self._config(prefetch_enabled=True)
|
|
279
|
+
marker = "- unique-fact-abc123 decided at standup"
|
|
280
|
+
|
|
281
|
+
recall_buffer.write_buffer(SESSION, marker, {})
|
|
282
|
+
recall_buffer.write_sentinel(SESSION)
|
|
283
|
+
|
|
284
|
+
# Turn N+1: consumer injects the buffered block once.
|
|
285
|
+
first = self._consume(config, _NoMemoryDirectiveClient())
|
|
286
|
+
self.assertIn("unique-fact-abc123", first)
|
|
287
|
+
|
|
288
|
+
# Turn N+1 Stop: a mutation turn whose recall returns empty -> the old
|
|
289
|
+
# buffer is invalidated, nothing fresh written.
|
|
290
|
+
empty_client = mock.Mock()
|
|
291
|
+
empty_client.recall.return_value = {"results": []}
|
|
292
|
+
hook_input = {"prompt": "next", "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
|
|
293
|
+
with patch("prefetch.retain_module.run_retain", return_value={"status": "ok"}), \
|
|
294
|
+
patch("prefetch.HindsightClient", return_value=empty_client), \
|
|
295
|
+
patch("prefetch.get_api_url", return_value="http://fake"), \
|
|
296
|
+
patch("prefetch._last_human_prompt", return_value="next"):
|
|
297
|
+
prefetch.run_prefetch(hook_input, config)
|
|
298
|
+
|
|
299
|
+
# Turn N+2: the same block must NOT be injected a second time.
|
|
300
|
+
second = self._consume(config, _NoMemoryDirectiveClient())
|
|
301
|
+
self.assertNotIn(
|
|
302
|
+
"unique-fact-abc123", second,
|
|
303
|
+
"a consumed buffer must not be re-injected after a mutation invalidated it",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class ReplyPathDoesNotBlockOnRecallTests(InvalidationBase):
|
|
308
|
+
"""Assertion (3): with prefetch on and a fresh buffer present, the reply
|
|
309
|
+
path (UserPromptSubmit consumer) joins the buffer and NEVER blocks on a
|
|
310
|
+
synchronous recall. Asserted on transport: client.recall raises if called."""
|
|
311
|
+
|
|
312
|
+
def test_fresh_buffer_served_without_calling_recall(self):
|
|
313
|
+
config = self._config(prefetch_enabled=True)
|
|
314
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory xyz", {})
|
|
315
|
+
recall_buffer.write_sentinel(SESSION)
|
|
316
|
+
|
|
317
|
+
class _ExplodingRecallClient:
|
|
318
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
319
|
+
return {"items": []}
|
|
320
|
+
|
|
321
|
+
def recall(self, bank_id, query, **kwargs):
|
|
322
|
+
raise AssertionError("reply path must not block on synchronous recall when a fresh buffer exists")
|
|
323
|
+
|
|
324
|
+
ctx = self._consume(config, _ExplodingRecallClient())
|
|
325
|
+
self.assertIn("a prefetched memory xyz", ctx)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
unittest.main()
|