switchroom 0.19.2 → 0.19.4

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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -0,0 +1,200 @@
1
+ """Switchroom hindsight-leverage PR2 (workstream A2) — coverage for the
2
+ ``recallContextTurns`` default flip 1 → 2 and its latency bound.
3
+
4
+ Two things are asserted here:
5
+
6
+ 1. **The default is now 2** (config.py + settings.json), so a bare follow-up
7
+ user message composes with its antecedent human turn — WITHOUT anyone
8
+ setting ``recallContextTurns`` explicitly.
9
+ 2. **The multi-turn composition is budget- and latency-bounded:**
10
+ - ``truncate_recall_query`` keeps the composed 2-turn query within
11
+ ``recallMaxQueryChars`` even when the antecedent turn is huge — the latest
12
+ turn is always preserved, oldest context is dropped first.
13
+ - ``read_transcript_messages(..., tail_bytes=N)`` parses only the trailing
14
+ N bytes of a large transcript, so the per-recall read stays O(tail_bytes)
15
+ regardless of how large the session ``.jsonl`` has grown. The last human
16
+ turns (which is all the slice needs) still land.
17
+
18
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ import unittest
26
+
27
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ if SCRIPTS_DIR not in sys.path:
29
+ sys.path.insert(0, SCRIPTS_DIR)
30
+
31
+ from lib.config import DEFAULTS # noqa: E402
32
+ from lib.content import compose_recall_query, truncate_recall_query # noqa: E402
33
+ from recall import read_transcript_messages # noqa: E402
34
+
35
+
36
+ class ContextTurnsDefaultIsTwo(unittest.TestCase):
37
+ """The A2 flip: bare follow-ups embed with their antecedent by default."""
38
+
39
+ def test_config_default_is_two(self):
40
+ self.assertEqual(DEFAULTS.get("recallContextTurns"), 2)
41
+
42
+ def test_settings_json_default_is_two(self):
43
+ settings_path = os.path.join(
44
+ os.path.dirname(SCRIPTS_DIR), "settings.json"
45
+ )
46
+ with open(settings_path, encoding="utf-8") as f:
47
+ settings = json.load(f)
48
+ self.assertEqual(settings.get("recallContextTurns"), 2)
49
+
50
+ def test_settings_and_config_defaults_agree(self):
51
+ settings_path = os.path.join(
52
+ os.path.dirname(SCRIPTS_DIR), "settings.json"
53
+ )
54
+ with open(settings_path, encoding="utf-8") as f:
55
+ settings = json.load(f)
56
+ for key in ("recallContextTurns", "recallTranscriptTailBytes"):
57
+ self.assertEqual(
58
+ settings.get(key),
59
+ DEFAULTS.get(key),
60
+ f"settings.json and config.py disagree on {key}",
61
+ )
62
+
63
+ def test_bare_followup_composes_with_antecedent_at_default_turns(self):
64
+ # Simulate what recall.py does with the *default* turn count: a bare
65
+ # pronoun follow-up must carry its antecedent human turn into the query.
66
+ turns = DEFAULTS["recallContextTurns"]
67
+ messages = [
68
+ {"role": "user", "content": "how is the ORCHID_PRIMARY database configured"},
69
+ {"role": "assistant", "content": "it runs Postgres 16 with PITR"},
70
+ ]
71
+ composed = compose_recall_query("and the port?", messages, turns)
72
+ self.assertIn("Prior context:", composed)
73
+ self.assertIn("ORCHID_PRIMARY", composed)
74
+ self.assertTrue(composed.rstrip().endswith("and the port?"))
75
+
76
+
77
+ class ComposedQueryStaysWithinBudget(unittest.TestCase):
78
+ """The composition can never blow the recall query char budget."""
79
+
80
+ def test_huge_antecedent_truncated_to_budget_keeping_latest(self):
81
+ max_chars = 800
82
+ huge_antecedent = "x" * 5000 # far larger than the whole budget
83
+ messages = [
84
+ {"role": "user", "content": huge_antecedent},
85
+ {"role": "assistant", "content": "y" * 5000},
86
+ ]
87
+ latest = "and what about staging?"
88
+ composed = compose_recall_query(latest, messages, 2)
89
+ # Pre-truncation the composed query is over budget (the antecedent is 5k).
90
+ self.assertGreater(len(composed), max_chars)
91
+ truncated = truncate_recall_query(composed, latest, max_chars)
92
+ # Bounded — never exceeds the budget.
93
+ self.assertLessEqual(len(truncated), max_chars)
94
+ # The latest turn is always preserved verbatim (never sacrificed to
95
+ # make room for context).
96
+ self.assertTrue(truncated.rstrip().endswith(latest))
97
+
98
+ def test_modest_context_fits_and_is_preserved(self):
99
+ max_chars = 800
100
+ messages = [
101
+ {"role": "user", "content": "the deploy target is ap-southeast-2"},
102
+ {"role": "assistant", "content": "noted, Sydney region"},
103
+ ]
104
+ latest = "and the fallback region?"
105
+ composed = compose_recall_query(latest, messages, 2)
106
+ truncated = truncate_recall_query(composed, latest, max_chars)
107
+ self.assertLessEqual(len(truncated), max_chars)
108
+ # Small enough to keep the context line intact.
109
+ self.assertIn("ap-southeast-2", truncated)
110
+ self.assertTrue(truncated.rstrip().endswith(latest))
111
+
112
+
113
+ class TranscriptTailReadIsBounded(unittest.TestCase):
114
+ """The per-recall transcript read is byte-tail-bounded (latency bound)."""
115
+
116
+ def _write_transcript(self, path, filler_rows, tail_rows):
117
+ with open(path, "w", encoding="utf-8") as f:
118
+ for r in filler_rows + tail_rows:
119
+ f.write(json.dumps(r) + "\n")
120
+
121
+ def test_tail_bytes_reads_only_the_trailing_lines(self):
122
+ tmpdir = tempfile.mkdtemp(prefix="recall-tail-")
123
+ try:
124
+ path = os.path.join(tmpdir, "t.jsonl")
125
+ # A large body of old turns (well over any small tail bound) …
126
+ filler = [
127
+ {"type": "user", "message": {"role": "user",
128
+ "content": "OLD_TURN_" + str(i) + " " + ("f" * 500)}}
129
+ for i in range(200)
130
+ ]
131
+ # … then the two recent turns we actually want to slice.
132
+ tail = [
133
+ {"type": "user", "message": {"role": "user",
134
+ "content": "RECENT_FACT the api key is FALCON_9_KEY"}},
135
+ {"type": "assistant", "message": {"role": "assistant",
136
+ "content": "RECENT_ANSWER got it"}},
137
+ ]
138
+ self._write_transcript(path, filler, tail)
139
+
140
+ # Small tail bound — must NOT parse the old filler, but MUST reach
141
+ # the recent turns at the end.
142
+ msgs = read_transcript_messages(path, tail_bytes=4096)
143
+ joined = json.dumps(msgs)
144
+ self.assertIn("RECENT_FACT", joined)
145
+ self.assertIn("RECENT_ANSWER", joined)
146
+ self.assertNotIn("OLD_TURN_0", joined)
147
+ # Bounded read yields far fewer than the 202 total rows.
148
+ self.assertLess(len(msgs), 200)
149
+ finally:
150
+ import shutil
151
+ shutil.rmtree(tmpdir, ignore_errors=True)
152
+
153
+ def test_tail_bytes_zero_reads_whole_file(self):
154
+ tmpdir = tempfile.mkdtemp(prefix="recall-tail-")
155
+ try:
156
+ path = os.path.join(tmpdir, "t.jsonl")
157
+ rows = [
158
+ {"type": "user", "message": {"role": "user", "content": "FIRST"}},
159
+ {"type": "assistant", "message": {"role": "assistant", "content": "mid"}},
160
+ {"type": "user", "message": {"role": "user", "content": "LAST"}},
161
+ ]
162
+ self._write_transcript(path, [], rows)
163
+ msgs = read_transcript_messages(path, tail_bytes=0)
164
+ joined = json.dumps(msgs)
165
+ self.assertIn("FIRST", joined)
166
+ self.assertIn("LAST", joined)
167
+ self.assertEqual(len(msgs), 3)
168
+ finally:
169
+ import shutil
170
+ shutil.rmtree(tmpdir, ignore_errors=True)
171
+
172
+ def test_small_file_under_bound_reads_fully(self):
173
+ # When the file is smaller than the tail bound, nothing is dropped —
174
+ # the partial-first-line trim only applies when we actually seek.
175
+ tmpdir = tempfile.mkdtemp(prefix="recall-tail-")
176
+ try:
177
+ path = os.path.join(tmpdir, "t.jsonl")
178
+ rows = [
179
+ {"type": "user", "message": {"role": "user", "content": "ALPHA"}},
180
+ {"type": "user", "message": {"role": "user", "content": "OMEGA"}},
181
+ ]
182
+ self._write_transcript(path, [], rows)
183
+ msgs = read_transcript_messages(path, tail_bytes=262144)
184
+ joined = json.dumps(msgs)
185
+ self.assertIn("ALPHA", joined)
186
+ self.assertIn("OMEGA", joined)
187
+ self.assertEqual(len(msgs), 2)
188
+ finally:
189
+ import shutil
190
+ shutil.rmtree(tmpdir, ignore_errors=True)
191
+
192
+ def test_missing_transcript_returns_empty(self):
193
+ self.assertEqual(read_transcript_messages("", tail_bytes=4096), [])
194
+ self.assertEqual(
195
+ read_transcript_messages("/nonexistent/nope.jsonl", tail_bytes=4096), []
196
+ )
197
+
198
+
199
+ if __name__ == "__main__":
200
+ unittest.main()
@@ -0,0 +1,477 @@
1
+ """Switchroom hindsight-leverage PR 1 (workstream A1 + A3 stage-1 telemetry).
2
+
3
+ Two single-concern guarantees:
4
+
5
+ 1. Query hygiene — the ``<channel …>`` transport envelope is stripped from
6
+ the recall query on BOTH the single-turn and the multi-turn (composed)
7
+ paths, so ~100-200 chars of chat_id/ts/user XML noise never reach the
8
+ embedding or consume the recallMaxQueryChars cap. The stripped query is
9
+ the one recorded in ``recall_log.jsonl`` (acceptance: no ``<channel``
10
+ substring in the logged query). The multi-turn fixture is a regression
11
+ guard for the future ``recallContextTurns`` default flip (A2): it locks
12
+ in that the composed query — both its "Prior context:" lines and its
13
+ trailing latest-query segment — is envelope-free.
14
+
15
+ 2. Telemetry — every non-cache recall log carries per-bank latency +
16
+ timeout flags, directives-fetch latency, total critical-path wall time,
17
+ and a derived ``deadline_hit`` (any bank hit its hard per-request
18
+ timeout). This is the A3 stage-1 baseline instrumentation, landed ahead
19
+ of the parallelism change so a fresh pre-A3 breach baseline can accrue.
20
+
21
+ Stdlib-only (unittest + mock); runs under ``python3 -m unittest discover
22
+ tests/``. Mirrors the harness in ``test_recall_integration.py``.
23
+ """
24
+
25
+ import io
26
+ import json
27
+ import os
28
+ import socket
29
+ import sys
30
+ import tempfile
31
+ import shutil
32
+ import unittest
33
+ from unittest.mock import patch
34
+
35
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
36
+ if SCRIPTS_DIR not in sys.path:
37
+ sys.path.insert(0, SCRIPTS_DIR)
38
+
39
+ import recall # noqa: E402
40
+ from lib.content import compose_recall_query, strip_channel_envelope # noqa: E402
41
+
42
+
43
+ def _memory(text, mem_type="fact", mentioned_at="2026-01-01", mem_id=None):
44
+ out = {"text": text, "type": mem_type, "mentioned_at": mentioned_at}
45
+ if mem_id is not None:
46
+ out["id"] = mem_id
47
+ return out
48
+
49
+
50
+ class _RecordingClient:
51
+ """Fake HindsightClient that records the exact `query` passed to recall().
52
+
53
+ Per-bank behaviour (results / exception) is configurable so a bank can be
54
+ made to raise a timeout for the telemetry tests.
55
+ """
56
+
57
+ def __init__(self, memories=None, directives=None, bank_behaviour=None):
58
+ self._memories = memories if memories is not None else []
59
+ self._directives = directives if directives is not None else []
60
+ # Maps bank_id -> Exception to raise (e.g. a timeout).
61
+ self._bank_behaviour = bank_behaviour or {}
62
+ self.queries = [] # every query string passed, in call order
63
+
64
+ def list_directives(self, bank_id, active_only=True, timeout=2):
65
+ return {"items": list(self._directives)}
66
+
67
+ def recall(self, bank_id, query, **kwargs):
68
+ self.queries.append(query)
69
+ exc = self._bank_behaviour.get(bank_id)
70
+ if exc is not None:
71
+ raise exc
72
+ return {"results": list(self._memories)}
73
+
74
+
75
+ def _run_main_with(client, prompt, config_extra=None):
76
+ """Invoke recall.main with a fake client; capture stdout JSON."""
77
+ hook_input = {
78
+ "prompt": prompt,
79
+ "session_id": "test-session",
80
+ "transcript_path": "",
81
+ "cwd": "/tmp",
82
+ }
83
+ config = {
84
+ "autoRecall": True,
85
+ "bankId": "test-bank",
86
+ "recallMaxTokens": 1024,
87
+ "recallBudget": "mid",
88
+ "recallContextTurns": 1,
89
+ "recallMaxQueryChars": 800,
90
+ "recallPromptPreamble": "",
91
+ }
92
+ if config_extra:
93
+ config.update(config_extra)
94
+
95
+ stdout = io.StringIO()
96
+ stderr = io.StringIO()
97
+ with patch.object(recall, "load_config", return_value=config), patch.object(
98
+ recall, "get_api_url", return_value="http://localhost:18888"
99
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
100
+ recall, "ensure_bank_mission", return_value=None
101
+ ), patch.object(recall, "write_state", return_value=None), patch(
102
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
103
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
104
+ recall.main()
105
+
106
+ raw = stdout.getvalue()
107
+ if not raw.strip():
108
+ return None, raw
109
+ parsed = json.loads(raw)
110
+ return parsed["hookSpecificOutput"]["additionalContext"], raw
111
+
112
+
113
+ WRAPPED = (
114
+ '<channel source="switchroom-telegram" chat_id="1000000001" '
115
+ 'message_id="42" user="testuser" ts="2026-07-20T10:00:00Z">'
116
+ "what did we decide about the auth flow</channel>"
117
+ )
118
+ BARE = "what did we decide about the auth flow"
119
+
120
+
121
+ class SingleTurnEnvelopeStrip(unittest.TestCase):
122
+ """The single-turn recall query must be the envelope-free inner text —
123
+ identical to what a bare (unwrapped) prompt produces."""
124
+
125
+ def test_wrapped_and_bare_produce_identical_query(self):
126
+ wrapped_client = _RecordingClient(memories=[_memory("m")])
127
+ bare_client = _RecordingClient(memories=[_memory("m")])
128
+ _run_main_with(wrapped_client, prompt=WRAPPED)
129
+ _run_main_with(bare_client, prompt=BARE)
130
+ self.assertEqual(len(wrapped_client.queries), 1)
131
+ self.assertEqual(len(bare_client.queries), 1)
132
+ self.assertEqual(wrapped_client.queries[0], bare_client.queries[0])
133
+ self.assertEqual(wrapped_client.queries[0], BARE)
134
+
135
+ def test_no_channel_substring_or_attrs_in_query(self):
136
+ client = _RecordingClient(memories=[_memory("m")])
137
+ _run_main_with(client, prompt=WRAPPED)
138
+ q = client.queries[0]
139
+ self.assertNotIn("<channel", q)
140
+ self.assertNotIn("chat_id", q)
141
+ self.assertNotIn("1000000001", q)
142
+ self.assertNotIn("ts=", q)
143
+
144
+
145
+ class MultiTurnComposedEnvelopeStrip(unittest.TestCase):
146
+ """Regression guard for the future recallContextTurns default flip (A2):
147
+ the composed multi-turn query must be envelope-free in BOTH the trailing
148
+ latest-query segment and the "Prior context:" lines."""
149
+
150
+ def test_composed_query_helper_strips_wrapped_latest(self):
151
+ # compose_recall_query is called directly here (the helper must be
152
+ # safe for any future caller, per A1). A wrapped latest query and a
153
+ # wrapped prior user turn must both be stripped.
154
+ messages = [
155
+ {"role": "user", "content": '<channel source="telegram" chat_id="9">'
156
+ "we were discussing the auth flow</channel>"},
157
+ {"role": "assistant", "content": "right, the OAuth refresh path"},
158
+ ]
159
+ composed = compose_recall_query(WRAPPED, messages, recall_context_turns=2)
160
+ self.assertNotIn("<channel", composed)
161
+ self.assertNotIn("chat_id", composed)
162
+ self.assertIn("Prior context:", composed)
163
+ # Trailing latest-query segment is the bare text.
164
+ self.assertTrue(composed.rstrip().endswith(BARE))
165
+ # Prior user turn text survives, stripped.
166
+ self.assertIn("we were discussing the auth flow", composed)
167
+
168
+ def test_composed_query_via_main_has_no_channel(self):
169
+ # End-to-end through main() with recallContextTurns=2 and a transcript
170
+ # on disk whose latest user turn is wrapped.
171
+ tmpdir = tempfile.mkdtemp(prefix="recall-transcript-")
172
+ try:
173
+ transcript = os.path.join(tmpdir, "t.jsonl")
174
+ rows = [
175
+ {"type": "user", "message": {"role": "user",
176
+ "content": "earlier i mentioned the ORCHID database"}},
177
+ {"type": "assistant", "message": {"role": "assistant",
178
+ "content": "noted, ORCHID_PRIMARY"}},
179
+ {"type": "user", "message": {"role": "user", "content": WRAPPED}},
180
+ ]
181
+ with open(transcript, "w", encoding="utf-8") as f:
182
+ for r in rows:
183
+ f.write(json.dumps(r) + "\n")
184
+
185
+ client = _RecordingClient(memories=[_memory("m")])
186
+ hook_input = {
187
+ "prompt": WRAPPED,
188
+ "session_id": "test-session",
189
+ "transcript_path": transcript,
190
+ "cwd": "/tmp",
191
+ }
192
+ config = {
193
+ "autoRecall": True,
194
+ "bankId": "test-bank",
195
+ "recallMaxTokens": 1024,
196
+ "recallBudget": "mid",
197
+ "recallContextTurns": 2,
198
+ "recallMaxQueryChars": 800,
199
+ "recallPromptPreamble": "",
200
+ }
201
+ stdout, stderr = io.StringIO(), io.StringIO()
202
+ with patch.object(recall, "load_config", return_value=config), patch.object(
203
+ recall, "get_api_url", return_value="http://localhost:18888"
204
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
205
+ recall, "ensure_bank_mission", return_value=None
206
+ ), patch.object(recall, "write_state", return_value=None), patch(
207
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
208
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
209
+ recall.main()
210
+
211
+ self.assertEqual(len(client.queries), 1)
212
+ q = client.queries[0]
213
+ self.assertNotIn("<channel", q)
214
+ self.assertNotIn("chat_id", q)
215
+ self.assertIn("Prior context:", q)
216
+ self.assertTrue(q.rstrip().endswith(BARE))
217
+ finally:
218
+ shutil.rmtree(tmpdir, ignore_errors=True)
219
+
220
+
221
+ class _LogTestBase(unittest.TestCase):
222
+ def setUp(self):
223
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-log-test-")
224
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
225
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
226
+
227
+ def tearDown(self):
228
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
229
+ if self._prev is None:
230
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
231
+ else:
232
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
233
+
234
+ def _read_log(self):
235
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
236
+ if not os.path.isfile(path):
237
+ return []
238
+ with open(path, encoding="utf-8") as f:
239
+ return [json.loads(line) for line in f if line.strip()]
240
+
241
+
242
+ class StrippedQueryInLog(_LogTestBase):
243
+ def test_logged_query_has_no_channel_envelope(self):
244
+ client = _RecordingClient(memories=[_memory("m", mem_id="m1")])
245
+ _run_main_with(client, prompt=WRAPPED)
246
+ entries = self._read_log()
247
+ self.assertEqual(len(entries), 1)
248
+ e = entries[0]
249
+ self.assertIn("query", e)
250
+ self.assertEqual(e["query"], BARE)
251
+ self.assertNotIn("<channel", e["query"])
252
+ self.assertNotIn("chat_id", e["query"])
253
+
254
+
255
+ class RecallTelemetryFields(_LogTestBase):
256
+ def test_telemetry_fields_present_on_success(self):
257
+ client = _RecordingClient(memories=[_memory("m", mem_id="m1")])
258
+ _run_main_with(client, prompt=BARE)
259
+ e = self._read_log()[0]
260
+ self.assertIn("total_elapsed_ms", e)
261
+ self.assertIsInstance(e["total_elapsed_ms"], int)
262
+ self.assertGreaterEqual(e["total_elapsed_ms"], 0)
263
+ self.assertIn("directives_elapsed_ms", e)
264
+ self.assertIsInstance(e["directives_elapsed_ms"], int)
265
+ self.assertIn("deadline_hit", e)
266
+ self.assertFalse(e["deadline_hit"])
267
+ # One bank timing record for the own bank, no timeout.
268
+ self.assertIn("bank_timings", e)
269
+ self.assertEqual(len(e["bank_timings"]), 1)
270
+ bt = e["bank_timings"][0]
271
+ self.assertEqual(bt["bank_id"], "test-bank")
272
+ self.assertFalse(bt["timed_out"])
273
+ self.assertIsInstance(bt["elapsed_ms"], int)
274
+
275
+ def test_additional_bank_gets_its_own_timing_record(self):
276
+ client = _RecordingClient(memories=[_memory("m", mem_id="m1")])
277
+ _run_main_with(
278
+ client,
279
+ prompt=BARE,
280
+ config_extra={"recallAdditionalBanks": ["shared-bank"]},
281
+ )
282
+ e = self._read_log()[0]
283
+ banks = [bt["bank_id"] for bt in e["bank_timings"]]
284
+ self.assertEqual(banks, ["test-bank", "shared-bank"])
285
+
286
+ def test_deadline_hit_true_when_a_bank_times_out(self):
287
+ # Own bank succeeds; the additional bank raises a socket timeout.
288
+ client = _RecordingClient(
289
+ memories=[_memory("m", mem_id="m1")],
290
+ bank_behaviour={"shared-bank": socket.timeout("timed out")},
291
+ )
292
+ _run_main_with(
293
+ client,
294
+ prompt=BARE,
295
+ config_extra={"recallAdditionalBanks": ["shared-bank"]},
296
+ )
297
+ e = self._read_log()[0]
298
+ self.assertTrue(e["deadline_hit"])
299
+ timings = {bt["bank_id"]: bt for bt in e["bank_timings"]}
300
+ self.assertFalse(timings["test-bank"]["timed_out"])
301
+ self.assertTrue(timings["shared-bank"]["timed_out"])
302
+
303
+ def test_non_timeout_error_is_not_deadline_hit(self):
304
+ # A 5xx / connection error is an error but NOT a deadline hit.
305
+ client = _RecordingClient(
306
+ memories=[_memory("m", mem_id="m1")],
307
+ bank_behaviour={"shared-bank": RuntimeError("HTTP 503 from server")},
308
+ )
309
+ _run_main_with(
310
+ client,
311
+ prompt=BARE,
312
+ config_extra={"recallAdditionalBanks": ["shared-bank"]},
313
+ )
314
+ e = self._read_log()[0]
315
+ self.assertFalse(e["deadline_hit"])
316
+ timings = {bt["bank_id"]: bt for bt in e["bank_timings"]}
317
+ self.assertFalse(timings["shared-bank"]["timed_out"])
318
+
319
+
320
+ class TotalFailureStillLogs(_LogTestBase):
321
+ """Review finding 1 — a turn where every bank times out AND directives
322
+ are empty produces no injected block, yet the telemetry row (the exact
323
+ breach event the baseline counts) must still be written."""
324
+
325
+ def test_own_bank_timeout_no_directives_still_writes_row(self):
326
+ client = _RecordingClient(
327
+ memories=[],
328
+ directives=[],
329
+ bank_behaviour={"test-bank": socket.timeout("timed out")},
330
+ )
331
+ ctx, raw = _run_main_with(client, prompt=BARE)
332
+ # No block injected (no directives, no memories).
333
+ self.assertIsNone(ctx)
334
+ self.assertEqual(raw.strip(), "")
335
+ # …but the telemetry row exists and records the deadline hit.
336
+ entries = self._read_log()
337
+ self.assertEqual(len(entries), 1)
338
+ e = entries[0]
339
+ self.assertFalse(e["cache_hit"])
340
+ self.assertTrue(e["deadline_hit"])
341
+ self.assertEqual(e["result_count"], 0)
342
+ self.assertEqual(len(e["bank_timings"]), 1)
343
+ self.assertTrue(e["bank_timings"][0]["timed_out"])
344
+ self.assertIsInstance(e["total_elapsed_ms"], int)
345
+
346
+ def test_query_field_is_bounded_excerpt(self):
347
+ # Review finding 5 — the stored query is a bounded excerpt (≤200)
348
+ # even when the real query is long; query_chars keeps the full length.
349
+ long_tail = "auth flow " * 200 # ~2000 chars, well over the 800 cap
350
+ client = _RecordingClient(memories=[_memory("m", mem_id="m1")])
351
+ _run_main_with(client, prompt="decide " + long_tail)
352
+ e = self._read_log()[0]
353
+ self.assertLessEqual(len(e["query"]), 200)
354
+ # Full (post-truncation) length is still recorded separately.
355
+ self.assertGreater(e["query_chars"], 200)
356
+
357
+
358
+ class CacheHitRowSchema(_LogTestBase):
359
+ """Review finding 3 — a cache-hit row carries the telemetry keys as
360
+ None/[] (no banks ran), never `deadline_hit: False`."""
361
+
362
+ def test_cache_hit_row_has_none_telemetry(self):
363
+ client = _RecordingClient(memories=[_memory("unused")])
364
+ with patch.object(recall, "_cache_ttl_secs", return_value=300), patch.object(
365
+ recall, "_cache_lookup", return_value="cached context block"
366
+ ):
367
+ _run_main_with(client, prompt=BARE)
368
+ # The cache hit returns before any client.recall call.
369
+ self.assertEqual(client.queries, [])
370
+ entries = self._read_log()
371
+ self.assertEqual(len(entries), 1)
372
+ e = entries[0]
373
+ self.assertTrue(e["cache_hit"])
374
+ self.assertIsNone(e["deadline_hit"])
375
+ self.assertEqual(e["bank_timings"], [])
376
+ self.assertIsNone(e["total_elapsed_ms"])
377
+ self.assertIsNone(e["directives_elapsed_ms"])
378
+ self.assertIsNone(e["query"])
379
+ # PR8 (#3369) — the bank_errored field is present on cache-hit rows
380
+ # for a uniform schema, and is None (NOT False) since no banks ran, so
381
+ # a reader can do row["bank_errored"] without a KeyError and the row is
382
+ # never miscounted as an observed no-error recall.
383
+ self.assertIn("bank_errored", e)
384
+ self.assertIsNone(e["bank_errored"])
385
+
386
+
387
+ class MultiEnvelopeStrip(unittest.TestCase):
388
+ """Review finding 6 — strip_channel_envelope now sits on the live query
389
+ path, so a coalesced multi-envelope prompt must keep every inner text,
390
+ not just the first."""
391
+
392
+ def test_single_envelope_unchanged(self):
393
+ self.assertEqual(
394
+ strip_channel_envelope('<channel source="telegram">hello there</channel>'),
395
+ "hello there",
396
+ )
397
+
398
+ def test_bare_text_unchanged(self):
399
+ self.assertEqual(strip_channel_envelope("no envelope here"), "no envelope here")
400
+
401
+ def test_two_envelopes_are_coalesced(self):
402
+ content = (
403
+ '<channel source="telegram" chat_id="1">first message</channel>'
404
+ '<channel source="telegram" chat_id="1">second message</channel>'
405
+ )
406
+ out = strip_channel_envelope(content)
407
+ self.assertIn("first message", out)
408
+ self.assertIn("second message", out)
409
+ self.assertNotIn("<channel", out)
410
+ self.assertNotIn("chat_id", out)
411
+
412
+ def test_three_envelopes_with_interleaved_whitespace(self):
413
+ content = (
414
+ '<channel source="telegram">alpha</channel>\n'
415
+ '<channel source="telegram">beta</channel>\n'
416
+ '<channel source="telegram">gamma</channel>'
417
+ )
418
+ out = strip_channel_envelope(content)
419
+ for tok in ("alpha", "beta", "gamma"):
420
+ self.assertIn(tok, out)
421
+ self.assertNotIn("<channel", out)
422
+
423
+
424
+ class TimeoutClassifier(unittest.TestCase):
425
+ """Unit coverage for _is_timeout_error's classification."""
426
+
427
+ def test_socket_timeout(self):
428
+ self.assertTrue(recall._is_timeout_error(socket.timeout("timed out")))
429
+
430
+ def test_builtin_timeout_error(self):
431
+ self.assertTrue(recall._is_timeout_error(TimeoutError("timed out")))
432
+
433
+ def test_urlerror_wrapping_timeout(self):
434
+ import urllib.error
435
+ self.assertTrue(
436
+ recall._is_timeout_error(urllib.error.URLError(socket.timeout("timed out")))
437
+ )
438
+
439
+ def test_runtime_error_with_timeout_message(self):
440
+ self.assertTrue(recall._is_timeout_error(RuntimeError("request timed out after 8s")))
441
+
442
+ def test_plain_http_error_is_not_timeout(self):
443
+ self.assertFalse(recall._is_timeout_error(RuntimeError("HTTP 503 from server")))
444
+
445
+ def test_connection_refused_is_not_timeout(self):
446
+ self.assertFalse(recall._is_timeout_error(ConnectionRefusedError("refused")))
447
+
448
+ def test_http_504_wrapper_with_timed_out_body_is_not_timeout(self):
449
+ # Review finding 4 — lib.client wraps HTTP errors as
450
+ # `RuntimeError("HTTP <code> from <url>: <body>")`. A 502/504 body
451
+ # containing an upstream proxy's "timed out" text is a SERVER status,
452
+ # not a client-side read deadline.
453
+ self.assertFalse(
454
+ recall._is_timeout_error(
455
+ RuntimeError("HTTP 504 from http://h/recall: upstream request timed out")
456
+ )
457
+ )
458
+
459
+ def test_direct_httperror_is_not_timeout(self):
460
+ import urllib.error
461
+ exc = urllib.error.HTTPError(
462
+ url="http://h/recall", code=504, msg="Gateway Timeout", hdrs=None, fp=None
463
+ )
464
+ self.assertFalse(recall._is_timeout_error(exc))
465
+
466
+ def test_runtimeerror_causing_httperror_is_not_timeout(self):
467
+ import urllib.error
468
+ http = urllib.error.HTTPError(
469
+ url="http://h/recall", code=502, msg="Bad Gateway", hdrs=None, fp=None
470
+ )
471
+ wrapper = RuntimeError("wrapped: connection timed out")
472
+ wrapper.__cause__ = http
473
+ self.assertFalse(recall._is_timeout_error(wrapper))
474
+
475
+
476
+ if __name__ == "__main__":
477
+ unittest.main()