switchroom 0.16.47 → 0.17.1

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.
Files changed (117) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1359 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +842 -832
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/format.ts +119 -17
  23. package/telegram-plugin/gateway/approvals-commands.ts +6 -2
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +535 -627
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
  33. package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  35. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  36. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  37. package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
  38. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  39. package/telegram-plugin/history.ts +5 -0
  40. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  41. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  42. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  43. package/telegram-plugin/registry/turns-schema.ts +65 -1
  44. package/telegram-plugin/session-tail.ts +26 -4
  45. package/telegram-plugin/slot-banner-driver.ts +42 -2
  46. package/telegram-plugin/status-query-telemetry.ts +100 -0
  47. package/telegram-plugin/stream-reply-handler.ts +15 -16
  48. package/telegram-plugin/subagent-watcher.ts +182 -30
  49. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  50. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  51. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  52. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  53. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  54. package/telegram-plugin/tests/format-consistency.test.ts +79 -0
  55. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  56. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  57. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  58. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  59. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  60. package/telegram-plugin/tests/model-command.test.ts +54 -1
  61. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  62. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  63. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  64. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  65. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  66. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  68. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  69. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  70. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  71. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  72. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  73. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  74. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  75. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  76. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  77. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  78. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  79. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  80. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  82. package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
  83. package/telegram-plugin/tests/welcome-text.test.ts +64 -0
  84. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  85. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  86. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  87. package/telegram-plugin/tool-activity-summary.ts +19 -0
  88. package/telegram-plugin/turn-flush-safety.ts +16 -1
  89. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  90. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  91. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  92. package/telegram-plugin/welcome-text.ts +13 -9
  93. package/telegram-plugin/worker-activity-feed.ts +75 -15
  94. package/vendor/hindsight-memory/CHANGELOG.md +66 -0
  95. package/vendor/hindsight-memory/README.md +5 -0
  96. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  97. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  98. package/vendor/hindsight-memory/scripts/lib/content.py +43 -4
  99. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  100. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  101. package/vendor/hindsight-memory/scripts/retain.py +79 -11
  102. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  103. package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
  104. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  105. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  106. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
  107. package/vendor/hindsight-memory/settings.json +4 -0
  108. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  109. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  110. package/vendor/hindsight-memory/tests/test_content.py +123 -0
  111. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  112. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  113. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  114. package/telegram-plugin/silent-reply.ts +0 -58
  115. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  116. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  117. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,111 @@
1
+ """Unit tests for config env casting (`lib.config._cast_env`) and the
2
+ client's request-timeout override clamp (`HindsightClient._resolve_timeout`).
3
+
4
+ Follow-ups from the #2816 review punch list:
5
+ - list cast: a value that parses as JSON but is NOT a list (e.g. `42`)
6
+ must return None (keep default, fail-open) instead of falling through
7
+ to comma-split and producing a junk one-element list. Malformed
8
+ intended-JSON (`[1,2`) also keeps the default; plain comma strings
9
+ still split.
10
+ - timeout override: zero/negative env values are clamped to >= 1 instead
11
+ of passing straight through to urlopen.
12
+
13
+ Stdlib-only.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import unittest
19
+
20
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
21
+ if SCRIPTS_DIR not in sys.path:
22
+ sys.path.insert(0, SCRIPTS_DIR)
23
+
24
+ from lib.config import _cast_env # noqa: E402
25
+ from lib.client import HindsightClient # noqa: E402
26
+
27
+
28
+ class CastEnvListTests(unittest.TestCase):
29
+ def test_valid_json_list(self):
30
+ self.assertEqual(_cast_env('["a", "b"]', list), ["a", "b"])
31
+
32
+ def test_empty_json_list(self):
33
+ self.assertEqual(_cast_env("[]", list), [])
34
+
35
+ def test_comma_string_splits(self):
36
+ self.assertEqual(_cast_env("a, b ,c", list), ["a", "b", "c"])
37
+
38
+ def test_comma_string_drops_empty_tokens(self):
39
+ self.assertEqual(_cast_env("a,,b,", list), ["a", "b"])
40
+
41
+ def test_single_bare_string(self):
42
+ # Not valid JSON, no commas → one-element list.
43
+ self.assertEqual(_cast_env("solo", list), ["solo"])
44
+
45
+ def test_json_non_list_scalar_returns_none(self):
46
+ # Parses as JSON int — not a list, not a comma string. Default kept.
47
+ self.assertIsNone(_cast_env("42", list))
48
+
49
+ def test_json_non_list_object_returns_none(self):
50
+ self.assertIsNone(_cast_env('{"a": 1}', list))
51
+
52
+ def test_json_string_returns_none(self):
53
+ # A JSON-quoted string is valid JSON but not a list.
54
+ self.assertIsNone(_cast_env('"tag"', list))
55
+
56
+ def test_malformed_json_array_returns_none(self):
57
+ # Looks like intended JSON but doesn't parse → default kept,
58
+ # NOT comma-split into junk like ['["a"', '"b"'].
59
+ self.assertIsNone(_cast_env('["a", "b"', list))
60
+
61
+ def test_malformed_json_object_returns_none(self):
62
+ self.assertIsNone(_cast_env('{"a": ', list))
63
+
64
+
65
+ class CastEnvOtherTypesTests(unittest.TestCase):
66
+ """Guard the neighbours the list change must not disturb."""
67
+
68
+ def test_int_valid(self):
69
+ self.assertEqual(_cast_env("30", int), 30)
70
+
71
+ def test_int_invalid_returns_none(self):
72
+ self.assertIsNone(_cast_env("thirty", int))
73
+
74
+ def test_bool_true_variants(self):
75
+ for v in ("true", "1", "yes", "TRUE"):
76
+ self.assertTrue(_cast_env(v, bool), v)
77
+
78
+ def test_bool_false(self):
79
+ self.assertFalse(_cast_env("false", bool))
80
+
81
+ def test_dict_valid(self):
82
+ self.assertEqual(_cast_env('{"a": 1}', dict), {"a": 1})
83
+
84
+ def test_dict_non_container_returns_none(self):
85
+ self.assertIsNone(_cast_env("42", dict))
86
+
87
+
88
+ class ResolveTimeoutTests(unittest.TestCase):
89
+ URL = "http://127.0.0.1:9999"
90
+
91
+ def _client(self, override):
92
+ return HindsightClient(self.URL, request_timeout_override=override)
93
+
94
+ def test_no_override_uses_caller_timeout(self):
95
+ self.assertEqual(self._client(None)._resolve_timeout(30), 30)
96
+
97
+ def test_valid_override_wins(self):
98
+ self.assertEqual(self._client(15)._resolve_timeout(30), 15)
99
+
100
+ def test_zero_override_clamped_to_one(self):
101
+ self.assertEqual(self._client(0)._resolve_timeout(30), 1)
102
+
103
+ def test_negative_override_clamped_to_one(self):
104
+ self.assertEqual(self._client(-5)._resolve_timeout(30), 1)
105
+
106
+ def test_one_passes_through(self):
107
+ self.assertEqual(self._client(1)._resolve_timeout(30), 1)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()
@@ -0,0 +1,126 @@
1
+ """Switchroom follow-up to #2830 — recall-side coverage of the tool-boundary
2
+ slice fix.
3
+
4
+ #2830 fixed silent-memory-loss in ``slice_last_turns_by_user_boundary``:
5
+ Claude Code emits tool results as ``role="user"`` messages, and the old
6
+ boundary counter treated each one as a turn. A tool-heavy turn (>=N sequential
7
+ tool rounds) could therefore fill a fixed-size window with tool_result
8
+ pseudo-turns and push the real human message OUTSIDE it.
9
+
10
+ That same slice function backs TWO call sites:
11
+ 1. the RETAIN window (covered by ``test_retain_window.py``), and
12
+ 2. the RECALL context slice, via ``compose_recall_query``.
13
+
14
+ #2830 shipped a test only for the retain path. This file closes the reviewer
15
+ nit by exercising the RECALL path end-to-end over a tool-heavy transcript:
16
+ the composed recall query must still carry the real human turn's text and must
17
+ not be truncated at the tool_result pseudo-boundaries.
18
+
19
+ These tests FAIL if the ``_is_tool_result_only_user_message`` guard is reverted
20
+ (the tool_result messages become boundaries again and the human turn is sliced
21
+ off), and pass with the guard in place.
22
+
23
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
24
+ """
25
+
26
+ import os
27
+ import sys
28
+ import unittest
29
+
30
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
31
+ if SCRIPTS_DIR not in sys.path:
32
+ sys.path.insert(0, SCRIPTS_DIR)
33
+
34
+ from lib.content import compose_recall_query # noqa: E402
35
+
36
+
37
+ def _human_msg(text: str) -> dict:
38
+ return {"role": "user", "content": text}
39
+
40
+
41
+ def _assistant_msg(text: str) -> dict:
42
+ return {"role": "assistant", "content": text}
43
+
44
+
45
+ def _tool_result_msg(tool_use_id: str, text: str) -> dict:
46
+ # Claude Code emits tool results as role="user" with a content list of
47
+ # tool_result blocks — exactly the shape read_transcript() produces.
48
+ return {
49
+ "role": "user",
50
+ "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": text}],
51
+ }
52
+
53
+
54
+ class ComposeRecallQueryToolHeavyTurn(unittest.TestCase):
55
+ """The recall context slice must count HUMAN turns, not tool_result
56
+ pseudo-turns — otherwise a tool-heavy turn drops the human's text from the
57
+ recall query, so recall searches on the tool output instead of what the
58
+ human actually said.
59
+ """
60
+
61
+ def test_tool_heavy_prior_turn_keeps_human_text_in_recall_context(self):
62
+ # A prior human turn stating a fact, then 3 sequential tool rounds,
63
+ # then the assistant answer. The current (latest) query is separate.
64
+ # recall_context_turns=2 asks for the latest turn + one prior HUMAN
65
+ # turn. OLD boundary semantics count the 3 tool_result "user" messages
66
+ # as turns and never reach the human fact; NEW semantics skip them and
67
+ # anchor to the real human turn, so its text lands in "Prior context:".
68
+ prior_fact = "my prod database is called ORCHID_PRIMARY"
69
+ messages = [
70
+ _human_msg(prior_fact),
71
+ _assistant_msg("let me look that up"),
72
+ _tool_result_msg("t1", "queried schema table 1"),
73
+ _assistant_msg("checking more"),
74
+ _tool_result_msg("t2", "queried schema table 2"),
75
+ _assistant_msg("one more"),
76
+ _tool_result_msg("t3", "queried schema table 3"),
77
+ _assistant_msg("here is your schema"),
78
+ ]
79
+ result = compose_recall_query(
80
+ "what port does it listen on",
81
+ messages,
82
+ recall_context_turns=2,
83
+ )
84
+ self.assertIn("Prior context:", result)
85
+ self.assertIn(
86
+ "ORCHID_PRIMARY",
87
+ result,
88
+ "human turn text was sliced out of the recall context by the "
89
+ "tool_result pseudo-boundaries (recall-side silent memory loss). "
90
+ "Composed query was: " + repr(result),
91
+ )
92
+ # The tool_result content must NOT leak in as if it were a human turn.
93
+ self.assertNotIn("queried schema table", result)
94
+
95
+ def test_recall_context_anchors_to_human_turns_across_tool_volume(self):
96
+ # Two prior human turns, the older one carrying a fact, each turn
97
+ # followed by tool rounds. recall_context_turns=3 (latest + 2 prior
98
+ # HUMAN turns) must reach back past ALL the tool_result messages to the
99
+ # oldest human turn — tool volume must not consume the turn budget.
100
+ oldest_fact = "the deploy key is FALCON_9_KEY"
101
+ messages = [
102
+ _human_msg(oldest_fact),
103
+ _assistant_msg("looking"),
104
+ _tool_result_msg("t1", "tool output alpha"),
105
+ _assistant_msg("more"),
106
+ _tool_result_msg("t2", "tool output beta"),
107
+ _human_msg("and remind me of the region too"),
108
+ _assistant_msg("checking region"),
109
+ _tool_result_msg("t3", "tool output gamma"),
110
+ _assistant_msg("region is ap-southeast-2"),
111
+ ]
112
+ result = compose_recall_query(
113
+ "put those together for me",
114
+ messages,
115
+ recall_context_turns=3,
116
+ )
117
+ self.assertIn("Prior context:", result)
118
+ # Both prior HUMAN turns survive; the oldest human fact is reached.
119
+ self.assertIn("FALCON_9_KEY", result)
120
+ self.assertIn("and remind me of the region too", result)
121
+ # No tool_result payload masquerades as human context.
122
+ self.assertNotIn("tool output", result)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -55,13 +55,35 @@ class _FakeClient:
55
55
  self._memories = memories if memories is not None else []
56
56
  self._recall_exc = recall_exc
57
57
  self._list_exc = list_exc
58
+ # One entry per recall() call — lets tests assert the tag-filter
59
+ # kwargs (upstream 962140eef) that main() passed per bank.
60
+ self.recall_calls = []
58
61
 
59
62
  def list_directives(self, bank_id, active_only=True, timeout=2):
60
63
  if self._list_exc is not None:
61
64
  raise self._list_exc
62
65
  return {"items": list(self._directives)}
63
66
 
64
- def recall(self, bank_id, query, max_tokens=1024, budget="mid", types=None, timeout=10):
67
+ def recall(
68
+ self,
69
+ bank_id,
70
+ query,
71
+ max_tokens=1024,
72
+ budget="mid",
73
+ types=None,
74
+ tags=None,
75
+ tags_match=None,
76
+ tag_groups=None,
77
+ timeout=10,
78
+ ):
79
+ self.recall_calls.append(
80
+ {
81
+ "bank_id": bank_id,
82
+ "tags": tags,
83
+ "tags_match": tags_match,
84
+ "tag_groups": tag_groups,
85
+ }
86
+ )
65
87
  if self._recall_exc is not None:
66
88
  raise self._recall_exc
67
89
  return {"results": list(self._memories)}
@@ -617,5 +639,67 @@ class OverlapGateIntegrationTests(unittest.TestCase):
617
639
  self.assertIsNone(ctx)
618
640
 
619
641
 
642
+ class RecallTagFilterIntegrationTests(unittest.TestCase):
643
+ """Upstream 962140eef port — tag filters flow through main() to each
644
+ per-bank recall call, composed with our additional-banks routing."""
645
+
646
+ def test_global_tags_passed_to_primary_bank(self):
647
+ client = _FakeClient(memories=[_memory("a fact")])
648
+ _run_main_with(
649
+ client,
650
+ config_extra={
651
+ "recallTags": ["memory_type:rule"],
652
+ "recallTagsMatch": "any_strict",
653
+ },
654
+ )
655
+ self.assertEqual(client.recall_calls[0]["tags"], ["memory_type:rule"])
656
+ self.assertEqual(client.recall_calls[0]["tags_match"], "any_strict")
657
+
658
+ def test_no_tags_match_sent_without_tags_or_groups(self):
659
+ client = _FakeClient(memories=[_memory("a fact")])
660
+ _run_main_with(client, config_extra={"recallTagsMatch": "all"})
661
+ self.assertIsNone(client.recall_calls[0]["tags"])
662
+ self.assertIsNone(client.recall_calls[0]["tags_match"])
663
+ self.assertIsNone(client.recall_calls[0]["tag_groups"])
664
+
665
+ def test_per_bank_filter_overrides_global_for_additional_bank(self):
666
+ client = _FakeClient(memories=[_memory("a fact")])
667
+ _run_main_with(
668
+ client,
669
+ config_extra={
670
+ "recallAdditionalBanks": ["shared-bank"],
671
+ "recallTags": ["tech_stack:supabase"],
672
+ "recallTagsMatch": "any",
673
+ "recallAdditionalBankFilters": {
674
+ "shared-bank": {
675
+ "recallTags": ["memory_type:rule"],
676
+ "recallTagsMatch": "all_strict",
677
+ }
678
+ },
679
+ },
680
+ )
681
+ primary, extra = client.recall_calls[0], client.recall_calls[1]
682
+ self.assertEqual(primary["bank_id"], "test-bank")
683
+ self.assertEqual(primary["tags"], ["tech_stack:supabase"])
684
+ self.assertEqual(primary["tags_match"], "any")
685
+ self.assertEqual(extra["bank_id"], "shared-bank")
686
+ self.assertEqual(extra["tags"], ["memory_type:rule"])
687
+ self.assertEqual(extra["tags_match"], "all_strict")
688
+
689
+ def test_additional_bank_without_override_inherits_global(self):
690
+ client = _FakeClient(memories=[_memory("a fact")])
691
+ _run_main_with(
692
+ client,
693
+ config_extra={
694
+ "recallAdditionalBanks": ["shared-bank"],
695
+ "recallTags": ["memory_type:rule"],
696
+ },
697
+ )
698
+ extra = client.recall_calls[1]
699
+ self.assertEqual(extra["tags"], ["memory_type:rule"])
700
+ # Global tags_match defaults are only sent when filters are active.
701
+ self.assertEqual(extra["tags_match"], None)
702
+
703
+
620
704
  if __name__ == "__main__":
621
705
  unittest.main()
@@ -0,0 +1,107 @@
1
+ """Unit tests for the recall tag-filter port (upstream 962140eef).
2
+
3
+ Covers the switchroom-specific composition points that upstream's own tests
4
+ cannot: the tag-filter fingerprint (`_tag_filter_sig`) and its inclusion in
5
+ the recall cache key (`_cache_key`). Tag filters change what the recall API
6
+ returns for an identical query, so a filter change within the cache TTL must
7
+ produce a cache MISS — otherwise stale, differently-filtered results would
8
+ be served.
9
+
10
+ Stdlib-only.
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import unittest
16
+
17
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
18
+ if SCRIPTS_DIR not in sys.path:
19
+ sys.path.insert(0, SCRIPTS_DIR)
20
+
21
+ import recall # noqa: E402
22
+
23
+
24
+ class TagFilterSigTests(unittest.TestCase):
25
+ def test_empty_filters_collapse_to_empty_string(self):
26
+ # Backward-compat: unused feature must not perturb existing keys.
27
+ self.assertEqual(recall._tag_filter_sig(None, None, None, {}), "")
28
+ self.assertEqual(recall._tag_filter_sig([], None, None, {}), "")
29
+
30
+ def test_tags_produce_nonempty_sig(self):
31
+ self.assertNotEqual(recall._tag_filter_sig(["memory_type:rule"], "any", None, {}), "")
32
+
33
+ def test_tag_groups_alone_produce_nonempty_sig(self):
34
+ groups = [{"op": "all", "tags": ["a", "b"]}]
35
+ self.assertNotEqual(recall._tag_filter_sig(None, "any", groups, {}), "")
36
+
37
+ def test_bank_filters_alone_produce_nonempty_sig(self):
38
+ filters = {"profile-bank": {"recallTags": ["memory_type:rule"]}}
39
+ self.assertNotEqual(recall._tag_filter_sig(None, None, None, filters), "")
40
+
41
+ def test_sig_is_deterministic(self):
42
+ a = recall._tag_filter_sig(["t1"], "all", None, {"b": {"recallTags": ["x"]}})
43
+ b = recall._tag_filter_sig(["t1"], "all", None, {"b": {"recallTags": ["x"]}})
44
+ self.assertEqual(a, b)
45
+
46
+ def test_sig_stable_across_dict_key_order(self):
47
+ f1 = {"a": {"recallTags": ["x"]}, "b": {"recallTags": ["y"]}}
48
+ f2 = {"b": {"recallTags": ["y"]}, "a": {"recallTags": ["x"]}}
49
+ self.assertEqual(
50
+ recall._tag_filter_sig(["t"], "any", None, f1),
51
+ recall._tag_filter_sig(["t"], "any", None, f2),
52
+ )
53
+
54
+ def test_different_tags_different_sig(self):
55
+ self.assertNotEqual(
56
+ recall._tag_filter_sig(["memory_type:rule"], "any", None, {}),
57
+ recall._tag_filter_sig(["memory_type:fact"], "any", None, {}),
58
+ )
59
+
60
+ def test_different_match_mode_different_sig(self):
61
+ self.assertNotEqual(
62
+ recall._tag_filter_sig(["t"], "any", None, {}),
63
+ recall._tag_filter_sig(["t"], "all_strict", None, {}),
64
+ )
65
+
66
+ def test_unserializable_falls_back_to_repr(self):
67
+ # A pathological config value must not raise; it still yields a
68
+ # non-empty signature distinguishing it from "no filters".
69
+ sig = recall._tag_filter_sig([object()], "any", None, {})
70
+ self.assertTrue(sig)
71
+
72
+
73
+ class CacheKeyTagFilterTests(unittest.TestCase):
74
+ """The tag-filter fingerprint must be part of the recall cache key."""
75
+
76
+ ARGS = ("s1", "what are the rules", "clerk", ["profile"], "42", "ken")
77
+
78
+ def test_no_filters_matches_legacy_key(self):
79
+ # Default arg == explicit "" — pre-feature cache keys are unchanged.
80
+ legacy = recall._cache_key(*self.ARGS)
81
+ explicit = recall._cache_key(*self.ARGS, "")
82
+ self.assertEqual(legacy, explicit)
83
+
84
+ def test_filters_change_the_key(self):
85
+ sig = recall._tag_filter_sig(["memory_type:rule"], "any", None, {})
86
+ self.assertNotEqual(recall._cache_key(*self.ARGS), recall._cache_key(*self.ARGS, sig))
87
+
88
+ def test_different_filters_different_keys(self):
89
+ sig_a = recall._tag_filter_sig(["memory_type:rule"], "any", None, {})
90
+ sig_b = recall._tag_filter_sig(["memory_type:fact"], "any", None, {})
91
+ self.assertNotEqual(recall._cache_key(*self.ARGS, sig_a), recall._cache_key(*self.ARGS, sig_b))
92
+
93
+ def test_per_bank_filter_change_changes_key(self):
94
+ # Editing only recallAdditionalBankFilters (e.g. for a sender bank)
95
+ # must also invalidate the cache.
96
+ sig_a = recall._tag_filter_sig(["t"], "any", None, {"profile": {"recallTags": ["x"]}})
97
+ sig_b = recall._tag_filter_sig(["t"], "any", None, {"profile": {"recallTags": ["y"]}})
98
+ self.assertNotEqual(recall._cache_key(*self.ARGS, sig_a), recall._cache_key(*self.ARGS, sig_b))
99
+
100
+ def test_same_filters_same_key(self):
101
+ sig1 = recall._tag_filter_sig(["t"], "all", None, {})
102
+ sig2 = recall._tag_filter_sig(["t"], "all", None, {})
103
+ self.assertEqual(recall._cache_key(*self.ARGS, sig1), recall._cache_key(*self.ARGS, sig2))
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()