switchroom 0.19.18 → 0.19.22
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 +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Switchroom #3619 — unit tests for the degraded-recall disclosure.
|
|
2
|
+
|
|
3
|
+
Measured 2026-07-26 across `recall_log.jsonl` fleet-wide: of 1280 rows carrying
|
|
4
|
+
`bank_timings`, 1148 (89.7%) had the agent's OWN bank time out, and only 28 of
|
|
5
|
+
those still returned any memories. That ran for weeks without anyone noticing,
|
|
6
|
+
because a degraded recall and an empty-but-healthy recall were byte-identical
|
|
7
|
+
from the agent's side: both emit nothing. The agent then answers from no
|
|
8
|
+
context while its CLAUDE.md tells it recall "auto-fires on every inbound
|
|
9
|
+
message", so it asserts "no prior context" rather than "I could not check".
|
|
10
|
+
|
|
11
|
+
`degraded_recall_notice` closes that. These tests pin the behaviour that makes
|
|
12
|
+
it trustworthy rather than noisy:
|
|
13
|
+
|
|
14
|
+
* It FIRES when the agent's own bank timed out or hard-errored.
|
|
15
|
+
* It does NOT fire for a side-bank-only failure — a shared profile bank
|
|
16
|
+
timing out does not mean the agent lost its own memory, and a notice that
|
|
17
|
+
cries wolf gets ignored exactly when it matters.
|
|
18
|
+
* It does NOT fire on a healthy-but-empty bank (the "you genuinely have no
|
|
19
|
+
relevant memories" turn), which is the whole distinction being drawn.
|
|
20
|
+
* The own bank is matched by `bank_id`, NEVER by position — fan-out
|
|
21
|
+
completion order is not the source of `bank_timings` order and a
|
|
22
|
+
positional match would silently report the wrong bank's health.
|
|
23
|
+
* It is emitted OUTSIDE the cached context: `_combine_context` puts it
|
|
24
|
+
first, and the cached `context_message` must never contain it, or a later
|
|
25
|
+
healthy cache hit replays a stale "recall was DEGRADED" notice.
|
|
26
|
+
|
|
27
|
+
Stdlib-only, no network, no server.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import socket
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
import unittest
|
|
38
|
+
from unittest.mock import patch
|
|
39
|
+
|
|
40
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
41
|
+
if SCRIPTS_DIR not in sys.path:
|
|
42
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
43
|
+
|
|
44
|
+
import recall # noqa: E402
|
|
45
|
+
from recall import ( # noqa: E402
|
|
46
|
+
_combine_context,
|
|
47
|
+
degraded_recall_notice,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
OWN = "overlord"
|
|
51
|
+
SIDE = "ken-profile"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def timing(bank_id, *, timed_out=False, errored=False, elapsed_ms=120):
|
|
55
|
+
"""A `bank_timings` entry in the exact shape recall.py appends at
|
|
56
|
+
recall.py:1809 (parallel path) and recall.py:1851 (serial path)."""
|
|
57
|
+
return {
|
|
58
|
+
"bank_id": bank_id,
|
|
59
|
+
"elapsed_ms": elapsed_ms,
|
|
60
|
+
"timed_out": timed_out,
|
|
61
|
+
"errored": errored,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestDegradedNoticeFires(unittest.TestCase):
|
|
66
|
+
def test_fires_when_own_bank_times_out(self):
|
|
67
|
+
notice = degraded_recall_notice(
|
|
68
|
+
OWN, [timing(OWN, timed_out=True, elapsed_ms=8003)]
|
|
69
|
+
)
|
|
70
|
+
self.assertIn("DEGRADED", notice)
|
|
71
|
+
self.assertIn("timed out", notice)
|
|
72
|
+
# The bank must be named — a generic notice gives the operator nothing
|
|
73
|
+
# to grep for when three agents share a topic.
|
|
74
|
+
self.assertIn(OWN, notice)
|
|
75
|
+
|
|
76
|
+
def test_fires_when_own_bank_hard_errors(self):
|
|
77
|
+
notice = degraded_recall_notice(OWN, [timing(OWN, errored=True)])
|
|
78
|
+
self.assertIn("DEGRADED", notice)
|
|
79
|
+
self.assertIn("was unreachable", notice)
|
|
80
|
+
self.assertNotIn("timed out", notice)
|
|
81
|
+
|
|
82
|
+
def test_fires_even_when_side_bank_returned_results(self):
|
|
83
|
+
"""The masking case that hid this for weeks: the own bank times out,
|
|
84
|
+
the smaller shared bank answers, so the turn LOOKS like a successful
|
|
85
|
+
recall (non-zero result_count) while the agent's own memory is gone."""
|
|
86
|
+
notice = degraded_recall_notice(
|
|
87
|
+
OWN,
|
|
88
|
+
[timing(OWN, timed_out=True, elapsed_ms=8001), timing(SIDE, elapsed_ms=940)],
|
|
89
|
+
)
|
|
90
|
+
self.assertIn("DEGRADED", notice)
|
|
91
|
+
|
|
92
|
+
def test_tells_the_agent_to_treat_absence_as_unknown(self):
|
|
93
|
+
"""The notice exists to change the agent's BEHAVIOUR on a degraded
|
|
94
|
+
turn. If it stops saying so, it is decoration."""
|
|
95
|
+
notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
|
|
96
|
+
self.assertIn("UNKNOWN", notice)
|
|
97
|
+
|
|
98
|
+
def test_is_a_single_short_line(self):
|
|
99
|
+
"""Fires on an already-starved turn; a verbose block would spend the
|
|
100
|
+
very budget the degradation is starving."""
|
|
101
|
+
notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
|
|
102
|
+
self.assertNotIn("\n", notice)
|
|
103
|
+
self.assertLess(len(notice), 500)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestDegradedNoticeSilent(unittest.TestCase):
|
|
107
|
+
def test_silent_when_own_bank_healthy_but_empty(self):
|
|
108
|
+
"""The distinction the whole feature draws: a healthy bank with nothing
|
|
109
|
+
relevant must stay silent, or the notice fires on ordinary turns and
|
|
110
|
+
gets tuned out."""
|
|
111
|
+
self.assertEqual(degraded_recall_notice(OWN, [timing(OWN)]), "")
|
|
112
|
+
|
|
113
|
+
def test_silent_when_only_a_side_bank_fails(self):
|
|
114
|
+
self.assertEqual(
|
|
115
|
+
degraded_recall_notice(
|
|
116
|
+
OWN, [timing(OWN, elapsed_ms=310), timing(SIDE, timed_out=True)]
|
|
117
|
+
),
|
|
118
|
+
"",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def test_silent_when_only_a_side_bank_hard_errors(self):
|
|
122
|
+
self.assertEqual(
|
|
123
|
+
degraded_recall_notice(OWN, [timing(OWN), timing(SIDE, errored=True)]),
|
|
124
|
+
"",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_silent_on_empty_or_missing_timings(self):
|
|
128
|
+
# Cache-hit and trivial-skip turns take an early return with no
|
|
129
|
+
# fan-out; nothing was attempted, so nothing is degraded.
|
|
130
|
+
self.assertEqual(degraded_recall_notice(OWN, []), "")
|
|
131
|
+
self.assertEqual(degraded_recall_notice(OWN, None), "")
|
|
132
|
+
self.assertEqual(degraded_recall_notice("", [timing(OWN, timed_out=True)]), "")
|
|
133
|
+
|
|
134
|
+
def test_silent_when_own_bank_absent_from_timings(self):
|
|
135
|
+
self.assertEqual(
|
|
136
|
+
degraded_recall_notice(OWN, [timing(SIDE, timed_out=True)]), ""
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def test_tolerates_malformed_timing_entries(self):
|
|
140
|
+
"""`bank_timings` is telemetry read back into a decision; a stray
|
|
141
|
+
non-dict must not take the whole hook down with a TypeError — the hook
|
|
142
|
+
failing is strictly worse than the notice not firing."""
|
|
143
|
+
self.assertEqual(
|
|
144
|
+
degraded_recall_notice(OWN, ["junk", None, timing(OWN)]), ""
|
|
145
|
+
)
|
|
146
|
+
self.assertIn(
|
|
147
|
+
"DEGRADED",
|
|
148
|
+
degraded_recall_notice(OWN, ["junk", timing(OWN, timed_out=True)]),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestOwnBankMatchedById(unittest.TestCase):
|
|
153
|
+
def test_matches_own_bank_by_id_not_position(self):
|
|
154
|
+
"""Own-bank-first is the CONSTRUCTION order of bank_specs
|
|
155
|
+
(recall.py:1716), not a guarantee a future refactor preserves. A
|
|
156
|
+
positional [0] match here would report the side bank's health as the
|
|
157
|
+
agent's own — silently, and in the direction of a false all-clear."""
|
|
158
|
+
timings = [timing(SIDE, elapsed_ms=900), timing(OWN, timed_out=True)]
|
|
159
|
+
notice = degraded_recall_notice(OWN, timings)
|
|
160
|
+
self.assertIn("DEGRADED", notice)
|
|
161
|
+
self.assertIn(OWN, notice)
|
|
162
|
+
self.assertNotIn(SIDE, notice)
|
|
163
|
+
|
|
164
|
+
def test_a_healthy_own_bank_listed_second_stays_silent(self):
|
|
165
|
+
timings = [timing(SIDE, timed_out=True), timing(OWN, elapsed_ms=400)]
|
|
166
|
+
self.assertEqual(degraded_recall_notice(OWN, timings), "")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestNoticeIsNotCached(unittest.TestCase):
|
|
170
|
+
"""The notice is per-turn state. `_combine_context`'s contract (recall.py
|
|
171
|
+
:1289) is that transient blocks are appended at EMIT time and kept out of
|
|
172
|
+
the string that reaches `_cache_store` / LAST_RECALL_STATE."""
|
|
173
|
+
|
|
174
|
+
def test_combine_puts_the_notice_first(self):
|
|
175
|
+
notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
|
|
176
|
+
context = "## Relevant memories\n- something"
|
|
177
|
+
combined = _combine_context(_combine_context(notice, context), "")
|
|
178
|
+
# First, because it changes how everything after it should be read.
|
|
179
|
+
self.assertTrue(combined.startswith(notice))
|
|
180
|
+
self.assertIn(context, combined)
|
|
181
|
+
|
|
182
|
+
def test_combine_with_notice_and_nudge_keeps_both(self):
|
|
183
|
+
notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
|
|
184
|
+
context = "## Relevant memories\n- something"
|
|
185
|
+
nudge = "[Hindsight] consider create_directive"
|
|
186
|
+
combined = _combine_context(_combine_context(notice, context), nudge)
|
|
187
|
+
self.assertTrue(combined.startswith(notice))
|
|
188
|
+
self.assertIn(context, combined)
|
|
189
|
+
self.assertTrue(combined.endswith(nudge))
|
|
190
|
+
|
|
191
|
+
def test_healthy_turn_combines_to_exactly_the_context(self):
|
|
192
|
+
"""No notice ⇒ emitted context is byte-identical to the cached one, so
|
|
193
|
+
a healthy turn is unchanged by this feature."""
|
|
194
|
+
notice = degraded_recall_notice(OWN, [timing(OWN)])
|
|
195
|
+
context = "## Relevant memories\n- something"
|
|
196
|
+
self.assertEqual(_combine_context(_combine_context(notice, context), ""), context)
|
|
197
|
+
|
|
198
|
+
def test_cached_context_source_excludes_the_notice(self):
|
|
199
|
+
"""Regression guard on the actual defect this could reintroduce:
|
|
200
|
+
if `degraded_block` were folded into `context_message`, that string is
|
|
201
|
+
what gets written to the cache (recall.py `_cache_store(cache_key,
|
|
202
|
+
context_message)`) and to LAST_RECALL_STATE — so the next HEALTHY turn
|
|
203
|
+
served from cache would replay "recall was DEGRADED" and the agent
|
|
204
|
+
would hedge for no reason."""
|
|
205
|
+
source = os.path.join(SCRIPTS_DIR, "recall.py")
|
|
206
|
+
with open(source, "r", encoding="utf-8") as fh:
|
|
207
|
+
body = fh.read()
|
|
208
|
+
# The composition of context_message must not reference degraded_block.
|
|
209
|
+
# Anchor on the assignment itself and the cache write, so a mutation
|
|
210
|
+
# that ADDS degraded_block to `parts` fails on the assertion below
|
|
211
|
+
# rather than on a missing anchor.
|
|
212
|
+
start_anchor = 'context_message = "\\n\\n".join(parts)'
|
|
213
|
+
end_anchor = "_cache_store(cache_key, context_message)"
|
|
214
|
+
self.assertIn(start_anchor, body, "context_message composition not found")
|
|
215
|
+
self.assertIn(end_anchor, body, "recall cache write not found")
|
|
216
|
+
# Walk back to the start of the `parts` accumulation.
|
|
217
|
+
start = body.rindex("parts = []", 0, body.index(start_anchor))
|
|
218
|
+
end = body.index(end_anchor)
|
|
219
|
+
self.assertNotIn(
|
|
220
|
+
"degraded_block",
|
|
221
|
+
body[start:end],
|
|
222
|
+
"degraded_block must not reach context_message: that string is "
|
|
223
|
+
"cached and would replay a stale DEGRADED notice on a healthy hit",
|
|
224
|
+
)
|
|
225
|
+
# And it must still reach the emitted additionalContext — the final
|
|
226
|
+
# emit site, which is the one after the cache write.
|
|
227
|
+
emit_at = body.index('"additionalContext"', end)
|
|
228
|
+
self.assertIn("degraded_block", body[emit_at : emit_at + 400])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _memory(text, mem_id):
|
|
232
|
+
return {"text": text, "type": "fact", "mentioned_at": "2026-01-01", "id": mem_id}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _Client:
|
|
236
|
+
"""Fake HindsightClient with per-bank results / exceptions."""
|
|
237
|
+
|
|
238
|
+
def __init__(self, bank_results=None, bank_errors=None):
|
|
239
|
+
self._bank_results = bank_results or {}
|
|
240
|
+
self._bank_errors = bank_errors or {}
|
|
241
|
+
|
|
242
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
243
|
+
return {"items": []}
|
|
244
|
+
|
|
245
|
+
def recall(self, bank_id, query, **kwargs):
|
|
246
|
+
exc = self._bank_errors.get(bank_id)
|
|
247
|
+
if exc is not None:
|
|
248
|
+
raise exc
|
|
249
|
+
return {"results": list(self._bank_results.get(bank_id, []))}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TestEndToEndEmit(unittest.TestCase):
|
|
253
|
+
"""Drive `recall.main()` end to end. The unit tests above pin the helper;
|
|
254
|
+
these pin that the notice actually REACHES `additionalContext`, which is
|
|
255
|
+
the only thing the agent ever sees."""
|
|
256
|
+
|
|
257
|
+
OWN = "test-bank"
|
|
258
|
+
SIDE = "shared-bank"
|
|
259
|
+
|
|
260
|
+
def setUp(self):
|
|
261
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-degraded-test-")
|
|
262
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
263
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
264
|
+
|
|
265
|
+
def tearDown(self):
|
|
266
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
267
|
+
if self._prev is None:
|
|
268
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
269
|
+
else:
|
|
270
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
271
|
+
|
|
272
|
+
def _run(self, client, config_extra=None):
|
|
273
|
+
hook_input = {
|
|
274
|
+
"prompt": "what did we decide about the auth flow",
|
|
275
|
+
"session_id": "test-session",
|
|
276
|
+
"transcript_path": "",
|
|
277
|
+
"cwd": "/tmp",
|
|
278
|
+
}
|
|
279
|
+
config = {
|
|
280
|
+
"autoRecall": True,
|
|
281
|
+
"bankId": self.OWN,
|
|
282
|
+
"recallMaxTokens": 1024,
|
|
283
|
+
"recallBudget": "mid",
|
|
284
|
+
"recallContextTurns": 1,
|
|
285
|
+
"recallMaxQueryChars": 800,
|
|
286
|
+
"recallPromptPreamble": "",
|
|
287
|
+
"recallParallelDeadlineSeconds": 5,
|
|
288
|
+
}
|
|
289
|
+
if config_extra:
|
|
290
|
+
config.update(config_extra)
|
|
291
|
+
stdout = io.StringIO()
|
|
292
|
+
stderr = io.StringIO()
|
|
293
|
+
self._cached = []
|
|
294
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
295
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
296
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
297
|
+
recall, "ensure_bank_mission", return_value=None
|
|
298
|
+
), patch.object(recall, "write_state", return_value=None), patch.object(
|
|
299
|
+
recall, "_cache_ttl_secs", return_value=300
|
|
300
|
+
), patch.object(recall, "_cache_lookup", return_value=None), patch.object(
|
|
301
|
+
recall,
|
|
302
|
+
"_cache_store",
|
|
303
|
+
side_effect=lambda key, value: self._cached.append(value),
|
|
304
|
+
), patch(
|
|
305
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
306
|
+
), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
|
|
307
|
+
recall.main()
|
|
308
|
+
raw = stdout.getvalue()
|
|
309
|
+
if not raw.strip():
|
|
310
|
+
return None
|
|
311
|
+
return json.loads(raw)["hookSpecificOutput"]["additionalContext"]
|
|
312
|
+
|
|
313
|
+
def test_masking_case_notice_rides_above_the_side_bank_memories(self):
|
|
314
|
+
"""The exact shape that hid the outage: own bank times out, the smaller
|
|
315
|
+
shared bank answers, so the turn looks successful. The agent must still
|
|
316
|
+
be told its own memory is missing."""
|
|
317
|
+
client = _Client(
|
|
318
|
+
bank_results={self.SIDE: [_memory("ken prefers TypeScript", "m1")]},
|
|
319
|
+
bank_errors={self.OWN: socket.timeout("timed out")},
|
|
320
|
+
)
|
|
321
|
+
ctx = self._run(client, {"recallAdditionalBanks": [self.SIDE]})
|
|
322
|
+
self.assertIsNotNone(ctx)
|
|
323
|
+
self.assertTrue(ctx.startswith("[Hindsight] Memory recall was DEGRADED"))
|
|
324
|
+
self.assertIn(self.OWN, ctx)
|
|
325
|
+
# The side bank's memories are still delivered — the notice qualifies
|
|
326
|
+
# them, it does not suppress them.
|
|
327
|
+
self.assertIn("ken prefers TypeScript", ctx)
|
|
328
|
+
|
|
329
|
+
def test_notice_is_not_written_to_the_recall_cache(self):
|
|
330
|
+
"""The cache write must carry the memories WITHOUT the notice, or the
|
|
331
|
+
next healthy turn served from cache replays a stale DEGRADED line."""
|
|
332
|
+
client = _Client(
|
|
333
|
+
bank_results={self.SIDE: [_memory("ken prefers TypeScript", "m1")]},
|
|
334
|
+
bank_errors={self.OWN: socket.timeout("timed out")},
|
|
335
|
+
)
|
|
336
|
+
self._run(client, {"recallAdditionalBanks": [self.SIDE]})
|
|
337
|
+
self.assertEqual(len(self._cached), 1)
|
|
338
|
+
self.assertNotIn("DEGRADED", self._cached[0])
|
|
339
|
+
self.assertIn("ken prefers TypeScript", self._cached[0])
|
|
340
|
+
|
|
341
|
+
def test_healthy_turn_emits_no_notice(self):
|
|
342
|
+
client = _Client(bank_results={self.OWN: [_memory("something", "m1")]})
|
|
343
|
+
ctx = self._run(client)
|
|
344
|
+
self.assertIsNotNone(ctx)
|
|
345
|
+
self.assertNotIn("DEGRADED", ctx)
|
|
346
|
+
|
|
347
|
+
def test_side_bank_only_timeout_emits_no_notice(self):
|
|
348
|
+
client = _Client(
|
|
349
|
+
bank_results={self.OWN: [_memory("something", "m1")]},
|
|
350
|
+
bank_errors={self.SIDE: socket.timeout("timed out")},
|
|
351
|
+
)
|
|
352
|
+
ctx = self._run(client, {"recallAdditionalBanks": [self.SIDE]})
|
|
353
|
+
self.assertIsNotNone(ctx)
|
|
354
|
+
self.assertNotIn("DEGRADED", ctx)
|
|
355
|
+
|
|
356
|
+
def test_total_failure_emits_the_notice_alone(self):
|
|
357
|
+
client = _Client(bank_errors={self.OWN: socket.timeout("timed out")})
|
|
358
|
+
ctx = self._run(client)
|
|
359
|
+
self.assertIsNotNone(ctx)
|
|
360
|
+
self.assertIn("DEGRADED", ctx)
|
|
361
|
+
self.assertNotIn("<hindsight_memories>", ctx)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
unittest.main()
|
|
@@ -329,10 +329,18 @@ class TotalFailureStillLogs(_LogTestBase):
|
|
|
329
329
|
bank_behaviour={"test-bank": socket.timeout("timed out")},
|
|
330
330
|
)
|
|
331
331
|
ctx, raw = _run_main_with(client, prompt=BARE)
|
|
332
|
-
# No
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
#
|
|
332
|
+
# No memories and no directives were injected — there were none to
|
|
333
|
+
# inject. Switchroom #3619: the turn is no longer SILENT, though. The
|
|
334
|
+
# own bank timed out, so the agent gets the one-line degraded
|
|
335
|
+
# disclosure and nothing else — previously "empty because the bank
|
|
336
|
+
# timed out" and "empty because the bank held nothing" were
|
|
337
|
+
# indistinguishable to the agent, which is what let a ~90% own-bank
|
|
338
|
+
# timeout rate hide for weeks.
|
|
339
|
+
self.assertIsNotNone(ctx)
|
|
340
|
+
self.assertIn("DEGRADED", ctx)
|
|
341
|
+
self.assertNotIn("<hindsight_memories>", ctx)
|
|
342
|
+
self.assertNotIn("<hindsight_directives>", ctx)
|
|
343
|
+
# …and the telemetry row still exists and records the deadline hit.
|
|
336
344
|
entries = self._read_log()
|
|
337
345
|
self.assertEqual(len(entries), 1)
|
|
338
346
|
e = entries[0]
|
|
@@ -167,6 +167,31 @@ class _Harness(unittest.TestCase):
|
|
|
167
167
|
context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
|
|
168
168
|
return context
|
|
169
169
|
|
|
170
|
+
def _assert_no_fallback(self, context):
|
|
171
|
+
"""Assert the transcript fallback did NOT fire.
|
|
172
|
+
|
|
173
|
+
Switchroom #3619: a degraded own-bank read now emits a one-line
|
|
174
|
+
DEGRADED disclosure, so "the hook emitted nothing at all" is no longer
|
|
175
|
+
a valid proxy for "the fallback did not fire" — assert on the fallback
|
|
176
|
+
block itself. These tests are about fallback GATING, not about whether
|
|
177
|
+
any context was produced.
|
|
178
|
+
|
|
179
|
+
Review follow-up (#3626): this deliberately does NOT tolerate a `None`
|
|
180
|
+
context. Every caller reaches it with the OWN bank hard-errored, which
|
|
181
|
+
is exactly the condition the disclosure must fire on, so `None` means
|
|
182
|
+
the disclosure regressed. An earlier `if context is None: return`
|
|
183
|
+
guard here made the `DEGRADED` assertion vacuous under precisely that
|
|
184
|
+
mutation — stubbing `degraded_block = ""` left all 12 tests in this
|
|
185
|
+
file green.
|
|
186
|
+
"""
|
|
187
|
+
self.assertIsNotNone(
|
|
188
|
+
context,
|
|
189
|
+
"own bank hard-errored, so the #3619 degraded disclosure must have "
|
|
190
|
+
"been emitted — a silent turn is the regression this guards",
|
|
191
|
+
)
|
|
192
|
+
self.assertNotIn("<hindsight_transcript_fallback>", context)
|
|
193
|
+
self.assertIn("DEGRADED", context)
|
|
194
|
+
|
|
170
195
|
|
|
171
196
|
class FiresOnEmptyFactLayer(_Harness):
|
|
172
197
|
def test_fires_when_all_banks_zero_and_no_deadline_hit(self):
|
|
@@ -264,7 +289,7 @@ class DoesNotFireWhenBankErrored(_Harness):
|
|
|
264
289
|
bank_errors={"own-bank": ConnectionRefusedError("connection refused")},
|
|
265
290
|
)
|
|
266
291
|
context = self._run(client, transcript_path=transcript)
|
|
267
|
-
self.
|
|
292
|
+
self._assert_no_fallback(context)
|
|
268
293
|
e = self._read_log()[0]
|
|
269
294
|
self.assertTrue(e["bank_errored"])
|
|
270
295
|
self.assertFalse(e["deadline_hit"])
|
|
@@ -283,7 +308,7 @@ class DoesNotFireWhenBankErrored(_Harness):
|
|
|
283
308
|
transcript_path=transcript,
|
|
284
309
|
config_extra={"recallParallel": False},
|
|
285
310
|
)
|
|
286
|
-
self.
|
|
311
|
+
self._assert_no_fallback(context)
|
|
287
312
|
e = self._read_log()[0]
|
|
288
313
|
self.assertEqual(e["recall_mode"], "serial")
|
|
289
314
|
self.assertTrue(e["bank_errored"])
|