switchroom 0.19.27 → 0.19.29
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 +16 -2
- package/dist/auth-broker/index.js +141 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +18 -2
- package/dist/cli/switchroom.js +864 -43
- package/dist/host-control/main.js +144 -9
- package/dist/vault/approvals/kernel-server.js +146 -9
- package/dist/vault/broker/server.js +146 -9
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +585 -50
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/CHANGELOG.md +50 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
- package/vendor/hindsight-memory/scripts/retain.py +41 -1
- package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -12,6 +12,8 @@ single deterministic id), 6 (watermark monotonicity + uuid-not-found), 7
|
|
|
12
12
|
bound ENQUEUES the remainder).
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import contextlib
|
|
16
|
+
import io
|
|
15
17
|
import json
|
|
16
18
|
import os
|
|
17
19
|
import shutil
|
|
@@ -41,12 +43,16 @@ class FakeDaemon:
|
|
|
41
43
|
|
|
42
44
|
def __init__(self):
|
|
43
45
|
self.docs = {} # document_id -> {content, metadata, async}
|
|
46
|
+
# switchroom: the observation_scopes kwarg each POST carried.
|
|
47
|
+
self.observation_scopes_seen = []
|
|
44
48
|
self.posts = [] # [(document_id, async_processing)]
|
|
45
49
|
self.fail = False
|
|
46
50
|
self.drop_async = False
|
|
47
51
|
|
|
48
52
|
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
49
|
-
metadata=None, tags=None, timeout=15, async_processing=True
|
|
53
|
+
metadata=None, tags=None, timeout=15, async_processing=True,
|
|
54
|
+
observation_scopes=None):
|
|
55
|
+
self.observation_scopes_seen.append(observation_scopes)
|
|
50
56
|
self.posts.append((document_id, async_processing))
|
|
51
57
|
if self.fail:
|
|
52
58
|
raise RuntimeError("simulated daemon failure")
|
|
@@ -390,6 +396,159 @@ class TestSidechainSkip(DurabilityTestBase):
|
|
|
390
396
|
self.assertIn(f"user turn {i}", blob)
|
|
391
397
|
|
|
392
398
|
|
|
399
|
+
class TestObservationScopes(DurabilityTestBase):
|
|
400
|
+
"""switchroom — the per-row observation scope on the LIVE retain paths.
|
|
401
|
+
|
|
402
|
+
Asserts the OUTCOME on the wire: what scope the Stop hook, the boot
|
|
403
|
+
reconciler, and the pending-queue drain actually POST with. Each of those
|
|
404
|
+
is a separate hand-enumerated kwarg list, so each needs its own pin — a
|
|
405
|
+
miss on any one silently drops that path back to per-tag scopes.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
def _hook(self, session="scopesess", n=5):
|
|
409
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
410
|
+
_write_transcript(tpath, n, session_prefix=session)
|
|
411
|
+
return {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
412
|
+
|
|
413
|
+
def _set_scope(self, value):
|
|
414
|
+
os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
|
|
415
|
+
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
|
|
416
|
+
|
|
417
|
+
# -- default: nothing changes -------------------------------------------
|
|
418
|
+
def test_unconfigured_stop_retain_posts_no_scope(self):
|
|
419
|
+
hook = self._hook("plainsess")
|
|
420
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
421
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
422
|
+
retain.main()
|
|
423
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
424
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
425
|
+
|
|
426
|
+
# -- Stop hook (retain.py) ----------------------------------------------
|
|
427
|
+
def test_configured_stop_retain_posts_the_scope(self):
|
|
428
|
+
self._set_scope("shared")
|
|
429
|
+
hook = self._hook("livesess")
|
|
430
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
431
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
432
|
+
retain.main()
|
|
433
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
434
|
+
self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
|
|
435
|
+
|
|
436
|
+
# -- boot reconciler (reconcile_tail.py) --------------------------------
|
|
437
|
+
def test_configured_boot_reconcile_posts_the_scope(self):
|
|
438
|
+
self._set_scope("shared")
|
|
439
|
+
hook = self._hook("reconsess")
|
|
440
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
441
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
442
|
+
self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
|
|
443
|
+
|
|
444
|
+
# -- durability: the scope survives the pending queue --------------------
|
|
445
|
+
def test_scope_survives_failure_enqueue_and_drain(self):
|
|
446
|
+
self._set_scope("shared")
|
|
447
|
+
hook = self._hook("failsess")
|
|
448
|
+
|
|
449
|
+
# The daemon refuses, so the Stop retain is ENQUEUED rather than landed.
|
|
450
|
+
self.daemon.fail = True
|
|
451
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
452
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
453
|
+
retain.main()
|
|
454
|
+
entries = self._pending_entries()
|
|
455
|
+
self.assertEqual(len(entries), 1)
|
|
456
|
+
_path, entry = entries[0]
|
|
457
|
+
# The queued entry carries the scope, so the drain does not have to
|
|
458
|
+
# re-resolve config that may have changed since.
|
|
459
|
+
self.assertEqual(entry["observation_scopes"], "shared")
|
|
460
|
+
|
|
461
|
+
# Drain it hours later: it lands in the SAME scope.
|
|
462
|
+
import drain_pending
|
|
463
|
+
seen = []
|
|
464
|
+
|
|
465
|
+
class _Client:
|
|
466
|
+
def __init__(self, *a, **kw):
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
def retain(self, **kwargs):
|
|
470
|
+
seen.append(kwargs.get("observation_scopes"))
|
|
471
|
+
return {"ok": True}
|
|
472
|
+
|
|
473
|
+
with mock.patch.object(drain_pending, "HindsightClient", _Client):
|
|
474
|
+
drain_pending._retry_one(entry, timeout=15)
|
|
475
|
+
self.assertEqual(seen, ["shared"])
|
|
476
|
+
|
|
477
|
+
# -- a typo must never destroy a memory ---------------------------------
|
|
478
|
+
#
|
|
479
|
+
# These are the regression tests for the worst bug the validation
|
|
480
|
+
# introduced. `build_retain_payload` used to RAISE on an off-list value.
|
|
481
|
+
# It is called from `run_retain`, which `retain.main` calls WITHOUT a
|
|
482
|
+
# try/except before its `pending_enqueue` — so the raise unwound past the
|
|
483
|
+
# enqueue: nothing POSTed, nothing queued, watermark not advanced, the turn
|
|
484
|
+
# gone. `session_end.py` caught it but had no payload to queue, and
|
|
485
|
+
# `session_start.py` swallowed the same raise into `debug_log` and aborted
|
|
486
|
+
# the reconcile loop that would have re-derived it. Every producer lost its
|
|
487
|
+
# memory outright, permanently and silently, for as long as the bad value
|
|
488
|
+
# sat in the config — switchroom #3244's shape, which this feature cites.
|
|
489
|
+
#
|
|
490
|
+
# A misconfigured scope is recoverable. A deleted turn is not. So a bad
|
|
491
|
+
# value degrades to the PRE-FEATURE behaviour (no field on the wire, the
|
|
492
|
+
# engine's own default scope) and is shouted about on stderr.
|
|
493
|
+
|
|
494
|
+
def test_a_typo_does_not_destroy_the_live_stop_retain(self):
|
|
495
|
+
self._set_scope("shred") # not "shared"
|
|
496
|
+
hook = self._hook("typolivesess")
|
|
497
|
+
err = io.StringIO()
|
|
498
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
499
|
+
mock.patch("sys.stdin", _stdin(hook)), \
|
|
500
|
+
contextlib.redirect_stderr(err):
|
|
501
|
+
retain.main()
|
|
502
|
+
|
|
503
|
+
# OUTCOME 1: the memory LANDED. Not "an exception was caught" — the
|
|
504
|
+
# turns are in the bank.
|
|
505
|
+
# (chunked mode with retainEveryNTurns=3 slices the last 3 turns)
|
|
506
|
+
blob = self.daemon.content_blob()
|
|
507
|
+
self.assertIn("user turn 4", blob)
|
|
508
|
+
# OUTCOME 2: at the engine's own default scope (field omitted), which
|
|
509
|
+
# is exactly what an unconfigured agent does.
|
|
510
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
511
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
512
|
+
# OUTCOME 3: and it was loud. A silent downgrade would be the original
|
|
513
|
+
# "typo ignored forever" defect.
|
|
514
|
+
self.assertIn("shred", err.getvalue())
|
|
515
|
+
self.assertIn("observation_scopes", err.getvalue())
|
|
516
|
+
|
|
517
|
+
def test_a_typo_does_not_destroy_a_FAILED_stop_retain(self):
|
|
518
|
+
# The reviewer's reproduction: with the raise in place this enqueued 0
|
|
519
|
+
# entries and left the watermark unmoved, so the turn was lost for good.
|
|
520
|
+
self._set_scope("shred")
|
|
521
|
+
hook = self._hook("typofailsess")
|
|
522
|
+
|
|
523
|
+
self.daemon.fail = True
|
|
524
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
525
|
+
mock.patch("sys.stdin", _stdin(hook)), \
|
|
526
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
527
|
+
retain.main()
|
|
528
|
+
|
|
529
|
+
# OUTCOME: the payload was still built and still ENQUEUED, so the next
|
|
530
|
+
# SessionStart drain replays it. This is the assertion that matters —
|
|
531
|
+
# the memory has a durable on-disk copy.
|
|
532
|
+
entries = self._pending_entries()
|
|
533
|
+
self.assertEqual(len(entries), 1)
|
|
534
|
+
_path, entry = entries[0]
|
|
535
|
+
self.assertIn("user turn 4", entry["content"])
|
|
536
|
+
self.assertIsNone(entry["observation_scopes"])
|
|
537
|
+
|
|
538
|
+
def test_a_typo_does_not_destroy_the_boot_reconcile(self):
|
|
539
|
+
# session_start.py swallows a reconcile raise into debug_log AND aborts
|
|
540
|
+
# the loop on the first bad payload, so the recovery path that would
|
|
541
|
+
# re-derive lost turns did nothing either.
|
|
542
|
+
self._set_scope("shred")
|
|
543
|
+
hook = self._hook("typoreconsess")
|
|
544
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
545
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
546
|
+
blob = self.daemon.content_blob()
|
|
547
|
+
self.assertIn("user turn 0", blob)
|
|
548
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
549
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
550
|
+
|
|
551
|
+
|
|
393
552
|
def _stdin(obj):
|
|
394
553
|
import io
|
|
395
554
|
return io.StringIO(json.dumps(obj))
|
|
@@ -289,7 +289,7 @@ class BoundedRead(unittest.TestCase):
|
|
|
289
289
|
|
|
290
290
|
|
|
291
291
|
class RunSubagentRetain(unittest.TestCase):
|
|
292
|
-
def _run(self, hook_input):
|
|
292
|
+
def _run(self, hook_input, config_extra=None):
|
|
293
293
|
captured = {}
|
|
294
294
|
|
|
295
295
|
class _Client:
|
|
@@ -300,7 +300,8 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
300
300
|
captured.update(kwargs)
|
|
301
301
|
return {"ok": True}
|
|
302
302
|
|
|
303
|
-
|
|
303
|
+
config = dict(CONFIG, **(config_extra or {}))
|
|
304
|
+
with mock.patch("subagent_retain.load_config", return_value=config), \
|
|
304
305
|
mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
|
|
305
306
|
mock.patch("subagent_retain.ensure_bank_mission"), \
|
|
306
307
|
mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
|
|
@@ -336,6 +337,43 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
336
337
|
self.assertEqual(captured["context"], "claude-code-sidechain")
|
|
337
338
|
self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
|
|
338
339
|
|
|
340
|
+
def test_sidechain_retain_omits_the_scope_when_unconfigured(self):
|
|
341
|
+
# switchroom — default behaviour must be byte-identical: the sidechain
|
|
342
|
+
# POST carries no scope, so the engine default stands.
|
|
343
|
+
with tempfile.TemporaryDirectory() as d:
|
|
344
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
345
|
+
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
346
|
+
hook_input = {
|
|
347
|
+
"session_id": "parentsess",
|
|
348
|
+
"agent_id": "af5",
|
|
349
|
+
"agent_transcript_path": sc,
|
|
350
|
+
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
351
|
+
"cwd": d,
|
|
352
|
+
}
|
|
353
|
+
result, captured = self._run(hook_input)
|
|
354
|
+
self.assertEqual(result["status"], "ok")
|
|
355
|
+
self.assertIsNone(captured["observation_scopes"])
|
|
356
|
+
|
|
357
|
+
def test_sidechain_retain_posts_the_configured_scope(self):
|
|
358
|
+
# switchroom — sidechain retains are their own hand-enumerated kwarg
|
|
359
|
+
# list; without this pin they would silently keep per-tag scopes while
|
|
360
|
+
# the parent session's retains moved to the shared one.
|
|
361
|
+
with tempfile.TemporaryDirectory() as d:
|
|
362
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
363
|
+
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
364
|
+
hook_input = {
|
|
365
|
+
"session_id": "parentsess",
|
|
366
|
+
"agent_id": "af5",
|
|
367
|
+
"agent_transcript_path": sc,
|
|
368
|
+
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
369
|
+
"cwd": d,
|
|
370
|
+
}
|
|
371
|
+
result, captured = self._run(
|
|
372
|
+
hook_input, config_extra={"observationScopes": "shared"}
|
|
373
|
+
)
|
|
374
|
+
self.assertEqual(result["status"], "ok")
|
|
375
|
+
self.assertEqual(captured["observation_scopes"], "shared")
|
|
376
|
+
|
|
339
377
|
def test_document_id_is_stable_across_refires(self):
|
|
340
378
|
with tempfile.TemporaryDirectory() as d:
|
|
341
379
|
sc = os.path.join(d, "agent-af5.jsonl")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"hindsightApiUrl": "",
|
|
3
3
|
"bankId": "claude_code",
|
|
4
4
|
"bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
|
|
5
|
-
"retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.",
|
|
5
|
+
"retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.",
|
|
6
6
|
"autoRecall": true,
|
|
7
7
|
"autoRetain": true,
|
|
8
8
|
"retainMode": "full-session",
|
|
@@ -104,9 +104,18 @@ class TestRecallHook:
|
|
|
104
104
|
raise OSError("connection refused")
|
|
105
105
|
|
|
106
106
|
hook_input = make_hook_input(prompt="What is my project about?")
|
|
107
|
-
# Should not raise — graceful degradation
|
|
107
|
+
# Should not raise — graceful degradation. Since switchroom #3619 that
|
|
108
|
+
# degradation is no longer SILENT: an unreachable OWN bank emits the
|
|
109
|
+
# degraded-recall disclosure, so the agent says "I could not check"
|
|
110
|
+
# rather than asserting there is no prior context. This case asserted
|
|
111
|
+
# the pre-#3619 silence and had been RED on main ever since. Nothing
|
|
112
|
+
# noticed because no CI job ran this file; #3688 wired the suite into
|
|
113
|
+
# ci-tests-python.yml, which is what surfaced it. The behaviour itself
|
|
114
|
+
# is pinned CI-side by scripts/tests/test_recall_degraded_notice.py.
|
|
108
115
|
output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
|
|
109
|
-
|
|
116
|
+
data = json.loads(output)
|
|
117
|
+
assert data["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
|
|
118
|
+
assert "DEGRADED" in data["hookSpecificOutput"]["additionalContext"]
|
|
110
119
|
|
|
111
120
|
def test_output_format_matches_claude_code_spec(self, monkeypatch, tmp_path):
|
|
112
121
|
memory = make_memory("User prefers Python")
|