switchroom 0.17.6 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Switchroom #2848 Stage C — unit tests for the Stop-hook directive verify.
|
|
2
|
+
|
|
3
|
+
Stage B (recall.py) nudges the model to persist a durable rule but still
|
|
4
|
+
relies on the model CHOOSING to call create_directive. Stage C
|
|
5
|
+
(directive_verify.py) closes the residual gap for the high-confidence case:
|
|
6
|
+
on Stop it re-checks the human turn against a NARROW durable-rule regex and,
|
|
7
|
+
if the turn stated a durable rule but recorded no create_directive call,
|
|
8
|
+
blocks the stop ONCE to re-prompt capture.
|
|
9
|
+
|
|
10
|
+
These tests pin:
|
|
11
|
+
* The high-precision durable detector — explicit standing-rule framings
|
|
12
|
+
fire; bare "always"/"stop …"/"don't …" and one-off requests do NOT
|
|
13
|
+
(the detector is a STRICT SUBSET of Stage B's inclusive one).
|
|
14
|
+
* Pleasantry scrub is inherited (Stage B negative guard).
|
|
15
|
+
* Transcript parsing — last human turn is isolated (tool_result-only and
|
|
16
|
+
injected recall/nudge blocks are skipped), and a create_directive
|
|
17
|
+
tool_use anywhere after it counts as captured.
|
|
18
|
+
* evaluate() end-to-end: block iff durable-rule-stated AND no directive
|
|
19
|
+
call AND feature on AND not stop_hook_active (loop guard).
|
|
20
|
+
* The block reason is the create_directive re-prompt with a one-off escape.
|
|
21
|
+
|
|
22
|
+
Stdlib-only.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
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 directive_verify import ( # noqa: E402
|
|
34
|
+
_VERIFY_BLOCK_REASON,
|
|
35
|
+
directive_recorded_after,
|
|
36
|
+
evaluate,
|
|
37
|
+
find_last_human_turn,
|
|
38
|
+
is_synthetic_inbound,
|
|
39
|
+
looks_like_durable_directive,
|
|
40
|
+
)
|
|
41
|
+
from recall import looks_like_standing_rule # noqa: E402
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Explicit durable standing-rule framings that MUST fire the verify block.
|
|
45
|
+
DURABLE_POSITIVES = [
|
|
46
|
+
"From now on, use British spelling in everything you write.",
|
|
47
|
+
"Going forward, never open a PR without a test plan.",
|
|
48
|
+
"In the future, always run the linter before you commit.",
|
|
49
|
+
"As a rule, keep replies under three sentences.",
|
|
50
|
+
"As a general policy, don't tag me on weekends.",
|
|
51
|
+
"You should always cite the file path when you quote code.",
|
|
52
|
+
"You should never push directly to main.",
|
|
53
|
+
"You must never ping the whole channel.",
|
|
54
|
+
"I want you to always double-check the branch name first.",
|
|
55
|
+
"Please always address me formally.",
|
|
56
|
+
"Please never use em dashes.",
|
|
57
|
+
"I'd prefer you keep the summaries terse.",
|
|
58
|
+
"I prefer that you use tabs, not spaces.",
|
|
59
|
+
"Call me Ken, not Kenneth.",
|
|
60
|
+
"Remember to always sign off with my initials.",
|
|
61
|
+
"Remember that I'm on Sydney time.",
|
|
62
|
+
"Don't ever guess at config values again.",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# Shapes that MUST NOT fire the verify block. Either genuine one-offs, or the
|
|
66
|
+
# inclusive-but-not-durable shapes Stage B nudges on but Stage C must not
|
|
67
|
+
# block on (bare always/never/stop without a standing frame), or pleasantries.
|
|
68
|
+
DURABLE_NEGATIVES = [
|
|
69
|
+
# one-off task requests
|
|
70
|
+
"Can you fix the failing test in auth.py?",
|
|
71
|
+
"Please deploy the staging branch now.",
|
|
72
|
+
"What's the status of the build?",
|
|
73
|
+
"Add a docstring to this function.",
|
|
74
|
+
"Run the tests and tell me what breaks.",
|
|
75
|
+
# inclusive-but-not-durable (Stage B may nudge, Stage C must not block):
|
|
76
|
+
# bare always/never/stop/don't and world-fact corrections carry too much
|
|
77
|
+
# one-off risk for a blocking re-prompt.
|
|
78
|
+
"always double-check that number for me here",
|
|
79
|
+
"stop the server so I can restart it",
|
|
80
|
+
"stop using that deprecated endpoint for this test",
|
|
81
|
+
"don't merge that yet, I'm still reviewing",
|
|
82
|
+
"that's wrong, the port is 8080",
|
|
83
|
+
"no longer needed for this one file, skip it",
|
|
84
|
+
"we don't use Jenkins anymore; it's all GitHub Actions now",
|
|
85
|
+
# pleasantries (shared negative scrub)
|
|
86
|
+
"always happy to help, thanks!",
|
|
87
|
+
"never mind, I figured it out.",
|
|
88
|
+
"as always, appreciate the quick turnaround.",
|
|
89
|
+
"thanks as always!",
|
|
90
|
+
# empty / junk
|
|
91
|
+
"",
|
|
92
|
+
" ",
|
|
93
|
+
"ok",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestDurableDetector(unittest.TestCase):
|
|
98
|
+
def test_positives_fire(self):
|
|
99
|
+
for text in DURABLE_POSITIVES:
|
|
100
|
+
with self.subTest(text=text):
|
|
101
|
+
self.assertTrue(
|
|
102
|
+
looks_like_durable_directive(text),
|
|
103
|
+
f"expected durable-rule detection to FIRE on: {text!r}",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_negatives_do_not_fire(self):
|
|
107
|
+
for text in DURABLE_NEGATIVES:
|
|
108
|
+
with self.subTest(text=text):
|
|
109
|
+
self.assertFalse(
|
|
110
|
+
looks_like_durable_directive(text),
|
|
111
|
+
f"expected durable-rule detection NOT to fire on: {text!r}",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def test_non_string_input(self):
|
|
115
|
+
for bad in (None, 123, [], {}, object()):
|
|
116
|
+
self.assertFalse(looks_like_durable_directive(bad))
|
|
117
|
+
|
|
118
|
+
def test_durable_is_subset_of_standing_rule(self):
|
|
119
|
+
# Every high-confidence durable positive must ALSO be caught by Stage
|
|
120
|
+
# B's inclusive detector — Stage C never blocks on something Stage B
|
|
121
|
+
# wouldn't have nudged on.
|
|
122
|
+
for text in DURABLE_POSITIVES:
|
|
123
|
+
with self.subTest(text=text):
|
|
124
|
+
self.assertTrue(
|
|
125
|
+
looks_like_standing_rule(text),
|
|
126
|
+
f"durable positive not a subset of standing_rule: {text!r}",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestTranscriptParsing(unittest.TestCase):
|
|
131
|
+
def test_find_last_human_turn_plain_string(self):
|
|
132
|
+
msgs = [
|
|
133
|
+
{"role": "user", "content": "hello"},
|
|
134
|
+
{"role": "assistant", "content": "hi"},
|
|
135
|
+
{"role": "user", "content": "from now on use tabs"},
|
|
136
|
+
]
|
|
137
|
+
idx, text = find_last_human_turn(msgs)
|
|
138
|
+
self.assertEqual(idx, 2)
|
|
139
|
+
self.assertEqual(text, "from now on use tabs")
|
|
140
|
+
|
|
141
|
+
def test_skips_tool_result_only_user_message(self):
|
|
142
|
+
msgs = [
|
|
143
|
+
{"role": "user", "content": "from now on use tabs"},
|
|
144
|
+
{
|
|
145
|
+
"role": "assistant",
|
|
146
|
+
"content": [{"type": "tool_use", "name": "Bash", "input": {}}],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"role": "user",
|
|
150
|
+
"content": [{"type": "tool_result", "content": "done"}],
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
idx, text = find_last_human_turn(msgs)
|
|
154
|
+
self.assertEqual(idx, 0)
|
|
155
|
+
self.assertEqual(text, "from now on use tabs")
|
|
156
|
+
|
|
157
|
+
def test_skips_injected_recall_block(self):
|
|
158
|
+
msgs = [
|
|
159
|
+
{"role": "user", "content": "call me Ken"},
|
|
160
|
+
{
|
|
161
|
+
"role": "user",
|
|
162
|
+
"content": "<hindsight_memories>old stuff</hindsight_memories>",
|
|
163
|
+
},
|
|
164
|
+
]
|
|
165
|
+
idx, text = find_last_human_turn(msgs)
|
|
166
|
+
self.assertEqual(idx, 0)
|
|
167
|
+
self.assertEqual(text, "call me Ken")
|
|
168
|
+
|
|
169
|
+
def test_extracts_text_from_list_content(self):
|
|
170
|
+
msgs = [
|
|
171
|
+
{
|
|
172
|
+
"role": "user",
|
|
173
|
+
"content": [
|
|
174
|
+
{"type": "text", "text": "as a rule, keep it terse"},
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
idx, text = find_last_human_turn(msgs)
|
|
179
|
+
self.assertEqual(idx, 0)
|
|
180
|
+
self.assertEqual(text, "as a rule, keep it terse")
|
|
181
|
+
|
|
182
|
+
def test_no_human_turn(self):
|
|
183
|
+
msgs = [{"role": "assistant", "content": "hi"}]
|
|
184
|
+
idx, text = find_last_human_turn(msgs)
|
|
185
|
+
self.assertIsNone(idx)
|
|
186
|
+
self.assertEqual(text, "")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TestDirectiveDetection(unittest.TestCase):
|
|
190
|
+
def _mk(self, name):
|
|
191
|
+
return {
|
|
192
|
+
"role": "assistant",
|
|
193
|
+
"content": [{"type": "tool_use", "name": name, "input": {}}],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def test_detects_mcp_create_directive(self):
|
|
197
|
+
msgs = [
|
|
198
|
+
{"role": "user", "content": "call me Ken"},
|
|
199
|
+
self._mk("mcp__hindsight__create_directive"),
|
|
200
|
+
]
|
|
201
|
+
self.assertTrue(directive_recorded_after(msgs, 0))
|
|
202
|
+
|
|
203
|
+
def test_detects_bare_create_directive(self):
|
|
204
|
+
msgs = [
|
|
205
|
+
{"role": "user", "content": "call me Ken"},
|
|
206
|
+
self._mk("create_directive"),
|
|
207
|
+
]
|
|
208
|
+
self.assertTrue(directive_recorded_after(msgs, 0))
|
|
209
|
+
|
|
210
|
+
def test_ignores_other_tools(self):
|
|
211
|
+
msgs = [
|
|
212
|
+
{"role": "user", "content": "call me Ken"},
|
|
213
|
+
self._mk("mcp__hindsight__recall"),
|
|
214
|
+
self._mk("Bash"),
|
|
215
|
+
]
|
|
216
|
+
self.assertFalse(directive_recorded_after(msgs, 0))
|
|
217
|
+
|
|
218
|
+
def test_only_counts_calls_after_index(self):
|
|
219
|
+
# A create_directive from an EARLIER turn does not count for this one.
|
|
220
|
+
msgs = [
|
|
221
|
+
self._mk("mcp__hindsight__create_directive"),
|
|
222
|
+
{"role": "user", "content": "call me Ken"},
|
|
223
|
+
{"role": "assistant", "content": "sure"},
|
|
224
|
+
]
|
|
225
|
+
self.assertFalse(directive_recorded_after(msgs, 1))
|
|
226
|
+
|
|
227
|
+
def _mk_id(self, name, tid):
|
|
228
|
+
return {
|
|
229
|
+
"role": "assistant",
|
|
230
|
+
"content": [
|
|
231
|
+
{"type": "tool_use", "id": tid, "name": name, "input": {}}
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def _result(self, tid, is_error):
|
|
236
|
+
return {
|
|
237
|
+
"role": "user",
|
|
238
|
+
"content": [
|
|
239
|
+
{
|
|
240
|
+
"type": "tool_result",
|
|
241
|
+
"tool_use_id": tid,
|
|
242
|
+
"is_error": is_error,
|
|
243
|
+
"content": "boom" if is_error else "ok",
|
|
244
|
+
}
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def test_errored_create_directive_is_not_recorded(self):
|
|
249
|
+
# SWITCHROOM DIVERGENCE #2903 Fix 1.3: a create_directive whose result
|
|
250
|
+
# errored must NOT count as recorded, so the verifier re-prompts once.
|
|
251
|
+
msgs = [
|
|
252
|
+
{"role": "user", "content": "call me Ken"},
|
|
253
|
+
self._mk_id("mcp__hindsight__create_directive", "toolu_1"),
|
|
254
|
+
self._result("toolu_1", True),
|
|
255
|
+
]
|
|
256
|
+
self.assertFalse(directive_recorded_after(msgs, 0))
|
|
257
|
+
|
|
258
|
+
def test_successful_create_directive_with_result_is_recorded(self):
|
|
259
|
+
msgs = [
|
|
260
|
+
{"role": "user", "content": "call me Ken"},
|
|
261
|
+
self._mk_id("mcp__hindsight__create_directive", "toolu_1"),
|
|
262
|
+
self._result("toolu_1", False),
|
|
263
|
+
]
|
|
264
|
+
self.assertTrue(directive_recorded_after(msgs, 0))
|
|
265
|
+
|
|
266
|
+
def test_one_failed_one_successful_directive_is_recorded(self):
|
|
267
|
+
msgs = [
|
|
268
|
+
{"role": "user", "content": "call me Ken"},
|
|
269
|
+
self._mk_id("mcp__hindsight__create_directive", "toolu_1"),
|
|
270
|
+
self._result("toolu_1", True),
|
|
271
|
+
self._mk_id("mcp__hindsight__create_directive", "toolu_2"),
|
|
272
|
+
self._result("toolu_2", False),
|
|
273
|
+
]
|
|
274
|
+
self.assertTrue(directive_recorded_after(msgs, 0))
|
|
275
|
+
|
|
276
|
+
def test_call_without_id_still_counts_as_attempt(self):
|
|
277
|
+
# Older/testing shape with no tool_use id has no pairable result.
|
|
278
|
+
msgs = [
|
|
279
|
+
{"role": "user", "content": "call me Ken"},
|
|
280
|
+
self._mk("mcp__hindsight__create_directive"),
|
|
281
|
+
]
|
|
282
|
+
self.assertTrue(directive_recorded_after(msgs, 0))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class TestEvaluate(unittest.TestCase):
|
|
286
|
+
ON = {"directiveCaptureNudge": True}
|
|
287
|
+
|
|
288
|
+
def _hook(self, messages, transcript_path, stop_hook_active=False):
|
|
289
|
+
return {
|
|
290
|
+
"transcript_path": transcript_path,
|
|
291
|
+
"stop_hook_active": stop_hook_active,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
def _write_transcript(self, messages):
|
|
295
|
+
import json
|
|
296
|
+
import tempfile
|
|
297
|
+
|
|
298
|
+
fd, path = tempfile.mkstemp(suffix=".jsonl")
|
|
299
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
300
|
+
for m in messages:
|
|
301
|
+
f.write(json.dumps({"type": m["role"], "message": m}) + "\n")
|
|
302
|
+
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
|
|
303
|
+
return path
|
|
304
|
+
|
|
305
|
+
def test_blocks_on_durable_rule_without_directive(self):
|
|
306
|
+
msgs = [
|
|
307
|
+
{"role": "user", "content": "From now on, always use British spelling."},
|
|
308
|
+
{"role": "assistant", "content": "Got it, will do."},
|
|
309
|
+
]
|
|
310
|
+
path = self._write_transcript(msgs)
|
|
311
|
+
reason = evaluate(self._hook(msgs, path), self.ON)
|
|
312
|
+
self.assertEqual(reason, _VERIFY_BLOCK_REASON)
|
|
313
|
+
|
|
314
|
+
def test_allows_when_directive_recorded(self):
|
|
315
|
+
msgs = [
|
|
316
|
+
{"role": "user", "content": "From now on, always use British spelling."},
|
|
317
|
+
{
|
|
318
|
+
"role": "assistant",
|
|
319
|
+
"content": [
|
|
320
|
+
{
|
|
321
|
+
"type": "tool_use",
|
|
322
|
+
"name": "mcp__hindsight__create_directive",
|
|
323
|
+
"input": {"text": "Always use British spelling."},
|
|
324
|
+
}
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
]
|
|
328
|
+
path = self._write_transcript(msgs)
|
|
329
|
+
self.assertIsNone(evaluate(self._hook(msgs, path), self.ON))
|
|
330
|
+
|
|
331
|
+
def test_allows_on_one_off_request(self):
|
|
332
|
+
msgs = [
|
|
333
|
+
{"role": "user", "content": "Please fix the failing test in auth.py."},
|
|
334
|
+
{"role": "assistant", "content": "Fixed."},
|
|
335
|
+
]
|
|
336
|
+
path = self._write_transcript(msgs)
|
|
337
|
+
self.assertIsNone(evaluate(self._hook(msgs, path), self.ON))
|
|
338
|
+
|
|
339
|
+
def test_allows_when_feature_disabled(self):
|
|
340
|
+
msgs = [
|
|
341
|
+
{"role": "user", "content": "From now on, always use British spelling."},
|
|
342
|
+
{"role": "assistant", "content": "Got it."},
|
|
343
|
+
]
|
|
344
|
+
path = self._write_transcript(msgs)
|
|
345
|
+
reason = evaluate(self._hook(msgs, path), {"directiveCaptureNudge": False})
|
|
346
|
+
self.assertIsNone(reason)
|
|
347
|
+
|
|
348
|
+
def test_loop_guard_does_not_reblock(self):
|
|
349
|
+
# Already blocked once this turn → respect model judgment, never loop.
|
|
350
|
+
msgs = [
|
|
351
|
+
{"role": "user", "content": "From now on, always use British spelling."},
|
|
352
|
+
{"role": "assistant", "content": "Got it."},
|
|
353
|
+
]
|
|
354
|
+
path = self._write_transcript(msgs)
|
|
355
|
+
reason = evaluate(
|
|
356
|
+
self._hook(msgs, path, stop_hook_active=True), self.ON
|
|
357
|
+
)
|
|
358
|
+
self.assertIsNone(reason)
|
|
359
|
+
|
|
360
|
+
def test_empty_transcript_allows(self):
|
|
361
|
+
self.assertIsNone(evaluate(self._hook([], "/nonexistent/path.jsonl"), self.ON))
|
|
362
|
+
|
|
363
|
+
def test_never_raises_on_garbage(self):
|
|
364
|
+
# Defensive: evaluate must degrade to None, never raise.
|
|
365
|
+
self.assertIsNone(evaluate({}, self.ON))
|
|
366
|
+
|
|
367
|
+
# --- #2903 Fix 6.2: verify-knob split + directive dedup ------------------
|
|
368
|
+
|
|
369
|
+
def test_verify_knob_off_keeps_nudge_but_drops_block(self):
|
|
370
|
+
# directiveCaptureNudge on (Stage B still nudges) but
|
|
371
|
+
# directiveCaptureVerify off → the Stop block must NOT fire.
|
|
372
|
+
msgs = [
|
|
373
|
+
{"role": "user", "content": "From now on, always use British spelling."},
|
|
374
|
+
{"role": "assistant", "content": "Got it."},
|
|
375
|
+
]
|
|
376
|
+
path = self._write_transcript(msgs)
|
|
377
|
+
cfg = {"directiveCaptureNudge": True, "directiveCaptureVerify": False}
|
|
378
|
+
self.assertIsNone(evaluate(self._hook(msgs, path), cfg))
|
|
379
|
+
# Sanity: with the knob left default (on) the same turn DOES block.
|
|
380
|
+
self.assertEqual(evaluate(self._hook(msgs, path), self.ON), _VERIFY_BLOCK_REASON)
|
|
381
|
+
|
|
382
|
+
def test_allows_when_rule_already_an_active_directive(self):
|
|
383
|
+
# The turn restates a rule that is ALREADY in the injected
|
|
384
|
+
# <active_directives> block; the model correctly declines to re-create
|
|
385
|
+
# it. The verifier must NOT block (dedup).
|
|
386
|
+
active_block = (
|
|
387
|
+
"<active_directives>\n"
|
|
388
|
+
"The following are HARD RULES the agent must follow on this turn.\n\n"
|
|
389
|
+
"1. [P10] spelling: Always use British spelling in all replies.\n"
|
|
390
|
+
"</active_directives>"
|
|
391
|
+
)
|
|
392
|
+
msgs = [
|
|
393
|
+
{"role": "user", "content": active_block},
|
|
394
|
+
{"role": "user", "content": "From now on, always use British spelling please."},
|
|
395
|
+
{"role": "assistant", "content": "That's already how I write — noted."},
|
|
396
|
+
]
|
|
397
|
+
path = self._write_transcript(msgs)
|
|
398
|
+
self.assertIsNone(evaluate(self._hook(msgs, path), self.ON))
|
|
399
|
+
|
|
400
|
+
def test_blocks_when_active_directive_is_a_different_rule(self):
|
|
401
|
+
# An unrelated active directive must NOT suppress the block for a new
|
|
402
|
+
# durable rule.
|
|
403
|
+
active_block = (
|
|
404
|
+
"<active_directives>\n"
|
|
405
|
+
"The following are HARD RULES the agent must follow on this turn.\n\n"
|
|
406
|
+
"1. [P10] tz: Always show times in UTC.\n"
|
|
407
|
+
"</active_directives>"
|
|
408
|
+
)
|
|
409
|
+
msgs = [
|
|
410
|
+
{"role": "user", "content": active_block},
|
|
411
|
+
{"role": "user", "content": "From now on, always use British spelling."},
|
|
412
|
+
{"role": "assistant", "content": "Got it."},
|
|
413
|
+
]
|
|
414
|
+
path = self._write_transcript(msgs)
|
|
415
|
+
self.assertEqual(evaluate(self._hook(msgs, path), self.ON), _VERIFY_BLOCK_REASON)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class TestSyntheticInbound(unittest.TestCase):
|
|
419
|
+
"""Non-interactive turns (cron / synthesized inbounds) must never trip the
|
|
420
|
+
blocking directive re-prompt — they aren't human corrections.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
def test_cron_source_is_synthetic(self):
|
|
424
|
+
self.assertTrue(
|
|
425
|
+
is_synthetic_inbound('<channel source="cron">Time for the daily digest.</channel>')
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def test_various_synthetic_sources(self):
|
|
429
|
+
for src in (
|
|
430
|
+
"cron",
|
|
431
|
+
"reaction",
|
|
432
|
+
"resume_interrupted",
|
|
433
|
+
"resume_watchdog_timeout",
|
|
434
|
+
"vault_grant_approved",
|
|
435
|
+
"subagent_handback",
|
|
436
|
+
"obligation_represent",
|
|
437
|
+
):
|
|
438
|
+
with self.subTest(src=src):
|
|
439
|
+
self.assertTrue(
|
|
440
|
+
is_synthetic_inbound(f'<channel source="{src}" foo="bar">body</channel>')
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def test_self_closing_synthetic_source(self):
|
|
444
|
+
self.assertTrue(is_synthetic_inbound('<channel source="reaction"/>'))
|
|
445
|
+
|
|
446
|
+
def test_telegram_source_is_human(self):
|
|
447
|
+
self.assertFalse(
|
|
448
|
+
is_synthetic_inbound('<channel source="telegram" chat_id="1" user="ken">hi</channel>')
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def test_no_envelope_is_human(self):
|
|
452
|
+
# Interactive session: no channel envelope → treated as human.
|
|
453
|
+
self.assertFalse(is_synthetic_inbound("From now on, always use British spelling."))
|
|
454
|
+
|
|
455
|
+
def test_non_string_is_not_synthetic(self):
|
|
456
|
+
for bad in (None, 123, [], {}):
|
|
457
|
+
self.assertFalse(is_synthetic_inbound(bad))
|
|
458
|
+
|
|
459
|
+
def test_cron_turn_with_durable_shape_does_not_block(self):
|
|
460
|
+
# A cron inbound whose text happens to contain a durable-rule phrasing
|
|
461
|
+
# must STILL be allowed — capture only makes sense on human turns.
|
|
462
|
+
import json
|
|
463
|
+
import tempfile
|
|
464
|
+
|
|
465
|
+
msgs = [
|
|
466
|
+
{
|
|
467
|
+
"role": "user",
|
|
468
|
+
"content": (
|
|
469
|
+
'<channel source="cron">Reminder: from now on, always '
|
|
470
|
+
"post the digest at 8am.</channel>"
|
|
471
|
+
),
|
|
472
|
+
},
|
|
473
|
+
{"role": "assistant", "content": "Posted the digest."},
|
|
474
|
+
]
|
|
475
|
+
fd, path = tempfile.mkstemp(suffix=".jsonl")
|
|
476
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
477
|
+
for m in msgs:
|
|
478
|
+
f.write(json.dumps({"type": m["role"], "message": m}) + "\n")
|
|
479
|
+
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
|
|
480
|
+
hook = {"transcript_path": path, "stop_hook_active": False}
|
|
481
|
+
self.assertIsNone(evaluate(hook, {"directiveCaptureNudge": True}))
|
|
482
|
+
|
|
483
|
+
def test_telegram_turn_with_durable_shape_still_blocks(self):
|
|
484
|
+
# Control: the SAME durable phrasing on a genuine telegram inbound
|
|
485
|
+
# must still block (proves the synthetic guard didn't over-reach).
|
|
486
|
+
import json
|
|
487
|
+
import tempfile
|
|
488
|
+
|
|
489
|
+
msgs = [
|
|
490
|
+
{
|
|
491
|
+
"role": "user",
|
|
492
|
+
"content": (
|
|
493
|
+
'<channel source="telegram" chat_id="1" user="ken">From now '
|
|
494
|
+
"on, always use British spelling.</channel>"
|
|
495
|
+
),
|
|
496
|
+
},
|
|
497
|
+
{"role": "assistant", "content": "Got it."},
|
|
498
|
+
]
|
|
499
|
+
fd, path = tempfile.mkstemp(suffix=".jsonl")
|
|
500
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
501
|
+
for m in msgs:
|
|
502
|
+
f.write(json.dumps({"type": m["role"], "message": m}) + "\n")
|
|
503
|
+
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
|
|
504
|
+
hook = {"transcript_path": path, "stop_hook_active": False}
|
|
505
|
+
self.assertEqual(evaluate(hook, {"directiveCaptureNudge": True}), _VERIFY_BLOCK_REASON)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class TestBlockReason(unittest.TestCase):
|
|
509
|
+
def test_reason_mentions_create_directive_and_escape_hatch(self):
|
|
510
|
+
self.assertIn("create_directive", _VERIFY_BLOCK_REASON)
|
|
511
|
+
self.assertIn("one-off", _VERIFY_BLOCK_REASON)
|
|
512
|
+
self.assertIn("<directive_capture_verify>", _VERIFY_BLOCK_REASON)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
unittest.main()
|
|
@@ -23,6 +23,8 @@ from lib.directives import ( # noqa: E402
|
|
|
23
23
|
MAX_DIRECTIVES,
|
|
24
24
|
fetch_active_directives,
|
|
25
25
|
format_active_directives_block,
|
|
26
|
+
parse_active_directives_block,
|
|
27
|
+
rule_already_captured,
|
|
26
28
|
)
|
|
27
29
|
|
|
28
30
|
|
|
@@ -207,5 +209,52 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
|
|
|
207
209
|
self.assertIn("(+3 more, omitted)", out)
|
|
208
210
|
|
|
209
211
|
|
|
212
|
+
class TestDirectiveDedup(unittest.TestCase):
|
|
213
|
+
"""#2903 Fix 6.2 — parse a rendered <active_directives> block back to
|
|
214
|
+
content strings, and detect whether a restated rule is already covered."""
|
|
215
|
+
|
|
216
|
+
def _block(self, *contents):
|
|
217
|
+
directives = [
|
|
218
|
+
{"name": f"d{i}", "content": c, "priority": 10 - i}
|
|
219
|
+
for i, c in enumerate(contents)
|
|
220
|
+
]
|
|
221
|
+
return format_active_directives_block(directives)
|
|
222
|
+
|
|
223
|
+
def test_roundtrip_parse(self):
|
|
224
|
+
block = self._block(
|
|
225
|
+
"Always use British spelling in replies.",
|
|
226
|
+
"Show all times in UTC.",
|
|
227
|
+
)
|
|
228
|
+
parsed = parse_active_directives_block(block)
|
|
229
|
+
self.assertEqual(
|
|
230
|
+
parsed,
|
|
231
|
+
["Always use British spelling in replies.", "Show all times in UTC."],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def test_parse_absent_block_is_empty(self):
|
|
235
|
+
self.assertEqual(parse_active_directives_block("no block here"), [])
|
|
236
|
+
self.assertEqual(parse_active_directives_block(None), [])
|
|
237
|
+
|
|
238
|
+
def test_captured_when_restated_rule_overlaps_existing(self):
|
|
239
|
+
contents = parse_active_directives_block(
|
|
240
|
+
self._block("Always use British spelling in all replies.")
|
|
241
|
+
)
|
|
242
|
+
self.assertTrue(
|
|
243
|
+
rule_already_captured("From now on, always use British spelling please.", contents)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def test_not_captured_for_unrelated_rule(self):
|
|
247
|
+
contents = parse_active_directives_block(self._block("Always show times in UTC."))
|
|
248
|
+
self.assertFalse(
|
|
249
|
+
rule_already_captured("From now on, always use British spelling.", contents)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def test_not_captured_against_empty_directives(self):
|
|
253
|
+
self.assertFalse(rule_already_captured("Always use British spelling.", []))
|
|
254
|
+
|
|
255
|
+
def test_empty_rule_is_not_captured(self):
|
|
256
|
+
self.assertFalse(rule_already_captured("", ["Always use British spelling."]))
|
|
257
|
+
|
|
258
|
+
|
|
210
259
|
if __name__ == "__main__":
|
|
211
260
|
unittest.main()
|
|
@@ -18,12 +18,13 @@ Stdlib-only; runs under `python3 -m unittest discover tests/`.
|
|
|
18
18
|
import os
|
|
19
19
|
import sys
|
|
20
20
|
import unittest
|
|
21
|
+
from unittest import mock
|
|
21
22
|
|
|
22
23
|
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
23
24
|
if SCRIPTS_DIR not in sys.path:
|
|
24
25
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
25
26
|
|
|
26
|
-
from retain import select_retain_window # noqa: E402
|
|
27
|
+
from retain import run_retain, select_retain_window # noqa: E402
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _transcript(num_turns: int) -> list:
|
|
@@ -257,5 +258,69 @@ class SelectRetainWindowForce(unittest.TestCase):
|
|
|
257
258
|
self.assertEqual(_user_turn_indices(result), [7, 8, 9]) # last 3 turns
|
|
258
259
|
|
|
259
260
|
|
|
261
|
+
class RunRetainStopHookActiveGuard(unittest.TestCase):
|
|
262
|
+
"""switchroom #2848 Phase 3 — blocked-Stop double-fire guard.
|
|
263
|
+
|
|
264
|
+
When directive_verify.py blocks a Stop, the model continues and the Stop
|
|
265
|
+
event fires AGAIN with ``stop_hook_active: true``. run_retain must no-op on
|
|
266
|
+
that re-fire — no store, no increment_turn_count — so the same logical turn
|
|
267
|
+
can't produce a duplicate transcript document or drift the cadence counter.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
_CONFIG = {"autoRetain": True, "retainEveryNTurns": 10, "retainMode": "chunked"}
|
|
271
|
+
|
|
272
|
+
def test_stop_hook_active_noops_no_store_no_increment(self):
|
|
273
|
+
hook_input = {
|
|
274
|
+
"session_id": "s1",
|
|
275
|
+
"transcript_path": "/some/path.jsonl",
|
|
276
|
+
"stop_hook_active": True,
|
|
277
|
+
}
|
|
278
|
+
with mock.patch("retain.load_config", return_value=self._CONFIG), \
|
|
279
|
+
mock.patch("retain.increment_turn_count") as inc, \
|
|
280
|
+
mock.patch("retain.read_transcript") as read_t, \
|
|
281
|
+
mock.patch("retain.get_api_url") as get_url:
|
|
282
|
+
result = run_retain(hook_input, force=False)
|
|
283
|
+
# Early-return skip with the dedicated reason.
|
|
284
|
+
self.assertEqual(result.get("status"), "skipped")
|
|
285
|
+
self.assertEqual(result.get("reason"), "stop_hook_active")
|
|
286
|
+
# No cadence increment, no transcript read, no API resolution/store.
|
|
287
|
+
inc.assert_not_called()
|
|
288
|
+
read_t.assert_not_called()
|
|
289
|
+
get_url.assert_not_called()
|
|
290
|
+
|
|
291
|
+
def test_first_stop_not_guarded(self):
|
|
292
|
+
# The FIRST Stop (no stop_hook_active) must NOT be short-circuited by
|
|
293
|
+
# this guard — it proceeds into normal retain flow (which we stub out
|
|
294
|
+
# right after the guard by making the transcript empty).
|
|
295
|
+
hook_input = {
|
|
296
|
+
"session_id": "s2",
|
|
297
|
+
"transcript_path": "/some/path.jsonl",
|
|
298
|
+
"stop_hook_active": False,
|
|
299
|
+
}
|
|
300
|
+
with mock.patch("retain.load_config", return_value=self._CONFIG), \
|
|
301
|
+
mock.patch("retain.read_transcript", return_value=[]) as read_t:
|
|
302
|
+
result = run_retain(hook_input, force=False)
|
|
303
|
+
# Got past the guard into the flow — proven by the transcript read.
|
|
304
|
+
read_t.assert_called_once()
|
|
305
|
+
self.assertEqual(result.get("status"), "skipped")
|
|
306
|
+
self.assertEqual(result.get("reason"), "empty transcript")
|
|
307
|
+
|
|
308
|
+
def test_forced_session_end_sweep_exempt_from_guard(self):
|
|
309
|
+
# A forced (SessionEnd) sweep is a distinct hook event and must always
|
|
310
|
+
# flush even if stop_hook_active happens to be set — the guard is
|
|
311
|
+
# gated on `not force`.
|
|
312
|
+
hook_input = {
|
|
313
|
+
"session_id": "s3",
|
|
314
|
+
"transcript_path": "/some/path.jsonl",
|
|
315
|
+
"stop_hook_active": True,
|
|
316
|
+
}
|
|
317
|
+
with mock.patch("retain.load_config", return_value=self._CONFIG), \
|
|
318
|
+
mock.patch("retain.read_transcript", return_value=[]) as read_t:
|
|
319
|
+
result = run_retain(hook_input, force=True)
|
|
320
|
+
# force=True bypasses the stop_hook_active guard → reaches transcript read.
|
|
321
|
+
read_t.assert_called_once()
|
|
322
|
+
self.assertEqual(result.get("reason"), "empty transcript")
|
|
323
|
+
|
|
324
|
+
|
|
260
325
|
if __name__ == "__main__":
|
|
261
326
|
unittest.main()
|