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.
- package/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +24 -8
- package/dist/cli/drive-write-pretool.mjs +0 -5
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +1359 -1030
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +53 -17
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +201 -56
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +8 -43
- package/telegram-plugin/dist/gateway/gateway.js +842 -832
- package/telegram-plugin/dist/server.js +8 -43
- package/telegram-plugin/format.ts +119 -17
- package/telegram-plugin/gateway/approvals-commands.ts +6 -2
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +535 -627
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/format-consistency.test.ts +79 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
- package/telegram-plugin/tests/welcome-text.test.ts +64 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/welcome-text.ts +13 -9
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +66 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +43 -4
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +79 -11
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +123 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- 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(
|
|
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()
|