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.
- package/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +109 -7
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
|
|
2
|
+
fallback when the fact layer is empty pre-reconcile.
|
|
3
|
+
|
|
4
|
+
Acceptance guarantees (outcomes, not code paths):
|
|
5
|
+
|
|
6
|
+
1. **Fires on all-zero + no deadline hit.** Every bank returns zero results
|
|
7
|
+
and no slot timed out → recall injects a ``<hindsight_transcript_fallback>``
|
|
8
|
+
block containing the recent session turn(s) that mention the query's
|
|
9
|
+
terms, and the recall_log row records ``transcript_fallback=True``.
|
|
10
|
+
|
|
11
|
+
2. **Does NOT fire when any bank returned memories.** A non-empty bank result
|
|
12
|
+
suppresses the fallback entirely (no fallback block, log False) — the
|
|
13
|
+
fact layer is not empty.
|
|
14
|
+
|
|
15
|
+
3. **Does NOT fire when a bank hit its deadline.** deadline_hit True (a bank
|
|
16
|
+
abandoned at the shared deadline) means the empty result set may be a
|
|
17
|
+
TIMEOUT, not a genuinely empty fact layer — the #3369 sequencing
|
|
18
|
+
constraint — so the fallback is suppressed even though results are empty.
|
|
19
|
+
|
|
20
|
+
4. **Byte bound holds.** With a small ``…MaxBytes``, only the transcript TAIL
|
|
21
|
+
is read: a query-matching line that lives only in the (discarded) HEAD is
|
|
22
|
+
never surfaced, and ``transcript_fallback_bytes_read`` never exceeds the
|
|
23
|
+
configured cap.
|
|
24
|
+
|
|
25
|
+
5. **Turn + char bounds hold.** ``…MaxTurns`` caps the number of injected
|
|
26
|
+
turns; ``…MaxChars`` caps the emitted characters and sets the
|
|
27
|
+
``transcript_fallback_truncated`` telemetry flag.
|
|
28
|
+
|
|
29
|
+
6. **Config gate + relevance.** ``recallTranscriptFallback=False`` disables it;
|
|
30
|
+
a transcript with no term overlap with the query never fires.
|
|
31
|
+
|
|
32
|
+
Stdlib-only (unittest); runs under ``python3 -m unittest discover tests/``
|
|
33
|
+
from ``scripts/``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import io
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
import unittest
|
|
45
|
+
from unittest.mock import patch
|
|
46
|
+
|
|
47
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
48
|
+
if SCRIPTS_DIR not in sys.path:
|
|
49
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
50
|
+
|
|
51
|
+
import recall # noqa: E402
|
|
52
|
+
|
|
53
|
+
# Query terms: decide, auth, flow, week (stopwords stripped by _overlap_tokens).
|
|
54
|
+
BARE = "what did we decide about the auth flow last week"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _memory(text, mem_id=None):
|
|
58
|
+
out = {"text": text, "type": "fact", "mentioned_at": "2026-01-01"}
|
|
59
|
+
if mem_id is not None:
|
|
60
|
+
out["id"] = mem_id
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Client:
|
|
65
|
+
"""Fake HindsightClient with per-bank sleep + result control."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, bank_sleep=None, bank_results=None, directives=None,
|
|
68
|
+
bank_errors=None):
|
|
69
|
+
self._bank_sleep = bank_sleep or {}
|
|
70
|
+
self._bank_results = bank_results or {}
|
|
71
|
+
self._directives = directives or []
|
|
72
|
+
# {bank_id: Exception instance} — raised (hard, non-timeout error) to
|
|
73
|
+
# simulate a connection-refused / 5xx / daemon-down outage.
|
|
74
|
+
self._bank_errors = bank_errors or {}
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self.recall_calls = []
|
|
77
|
+
|
|
78
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
79
|
+
return {"items": list(self._directives)}
|
|
80
|
+
|
|
81
|
+
def recall(self, bank_id, query, **kwargs):
|
|
82
|
+
with self._lock:
|
|
83
|
+
self.recall_calls.append(bank_id)
|
|
84
|
+
sleep_s = self._bank_sleep.get(bank_id, 0.0)
|
|
85
|
+
if sleep_s:
|
|
86
|
+
time.sleep(sleep_s)
|
|
87
|
+
err = self._bank_errors.get(bank_id)
|
|
88
|
+
if err is not None:
|
|
89
|
+
raise err
|
|
90
|
+
return {"results": list(self._bank_results.get(bank_id, []))}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _nested_line(role, text):
|
|
94
|
+
"""One Claude Code nested-format transcript line."""
|
|
95
|
+
return json.dumps({
|
|
96
|
+
"type": role,
|
|
97
|
+
"uuid": f"u-{abs(hash((role, text))) % 10_000_000}",
|
|
98
|
+
"message": {"role": role, "content": text},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _Harness(unittest.TestCase):
|
|
103
|
+
def setUp(self):
|
|
104
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-fallback-test-")
|
|
105
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
106
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
107
|
+
|
|
108
|
+
def tearDown(self):
|
|
109
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
110
|
+
if self._prev is None:
|
|
111
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
112
|
+
else:
|
|
113
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
114
|
+
|
|
115
|
+
def _write_transcript(self, lines):
|
|
116
|
+
path = os.path.join(self._tmpdir, "transcript.jsonl")
|
|
117
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
118
|
+
f.write("\n".join(lines) + "\n")
|
|
119
|
+
return path
|
|
120
|
+
|
|
121
|
+
def _read_log(self):
|
|
122
|
+
path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
|
|
123
|
+
if not os.path.isfile(path):
|
|
124
|
+
return []
|
|
125
|
+
with open(path, encoding="utf-8") as f:
|
|
126
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
127
|
+
|
|
128
|
+
def _run(self, client, config_extra=None, prompt=BARE, transcript_path=""):
|
|
129
|
+
hook_input = {
|
|
130
|
+
"prompt": prompt,
|
|
131
|
+
"session_id": "test-session",
|
|
132
|
+
"transcript_path": transcript_path,
|
|
133
|
+
"cwd": "/tmp",
|
|
134
|
+
}
|
|
135
|
+
config = {
|
|
136
|
+
"autoRecall": True,
|
|
137
|
+
"bankId": "own-bank",
|
|
138
|
+
"recallMaxTokens": 1024,
|
|
139
|
+
"recallBudget": "mid",
|
|
140
|
+
"recallContextTurns": 1,
|
|
141
|
+
"recallMaxQueryChars": 800,
|
|
142
|
+
"recallPromptPreamble": "",
|
|
143
|
+
# Keep the shared deadline generous unless a test overrides it.
|
|
144
|
+
"recallParallelDeadlineSeconds": 5,
|
|
145
|
+
# Fallback bounds (defaults mirror config.py; explicit for clarity).
|
|
146
|
+
"recallTranscriptFallback": True,
|
|
147
|
+
"recallTranscriptFallbackMaxBytes": 262144,
|
|
148
|
+
"recallTranscriptFallbackMaxTurns": 6,
|
|
149
|
+
"recallTranscriptFallbackMaxChars": 2000,
|
|
150
|
+
"recallTranscriptFallbackDeadlineMs": 1500,
|
|
151
|
+
}
|
|
152
|
+
if config_extra:
|
|
153
|
+
config.update(config_extra)
|
|
154
|
+
stdout = io.StringIO()
|
|
155
|
+
stderr = io.StringIO()
|
|
156
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
157
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
158
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
159
|
+
recall, "ensure_bank_mission", return_value=None
|
|
160
|
+
), patch.object(recall, "write_state", return_value=None), patch(
|
|
161
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
162
|
+
), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
|
|
163
|
+
recall.main()
|
|
164
|
+
raw = stdout.getvalue()
|
|
165
|
+
context = None
|
|
166
|
+
if raw.strip():
|
|
167
|
+
context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
|
|
168
|
+
return context
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class FiresOnEmptyFactLayer(_Harness):
|
|
172
|
+
def test_fires_when_all_banks_zero_and_no_deadline_hit(self):
|
|
173
|
+
transcript = self._write_transcript([
|
|
174
|
+
_nested_line("user", "hello there"),
|
|
175
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
176
|
+
_nested_line("assistant", "agreed, the auth flow now uses PKCE"),
|
|
177
|
+
_nested_line("user", "thanks"),
|
|
178
|
+
])
|
|
179
|
+
client = _Client(bank_results={"own-bank": []})
|
|
180
|
+
context = self._run(client, transcript_path=transcript)
|
|
181
|
+
|
|
182
|
+
self.assertIsNotNone(context, "fallback should have produced context")
|
|
183
|
+
self.assertIn("<hindsight_transcript_fallback>", context)
|
|
184
|
+
self.assertIn("PKCE", context)
|
|
185
|
+
# Chronological order preserved (user turn before assistant turn).
|
|
186
|
+
self.assertLess(
|
|
187
|
+
context.index("we should redo the auth flow"),
|
|
188
|
+
context.index("the auth flow now uses PKCE"),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
e = self._read_log()[0]
|
|
192
|
+
self.assertTrue(e["transcript_fallback"])
|
|
193
|
+
self.assertGreaterEqual(e["transcript_fallback_turns"], 2)
|
|
194
|
+
self.assertGreater(e["transcript_fallback_chars"], 0)
|
|
195
|
+
self.assertFalse(e["deadline_hit"])
|
|
196
|
+
self.assertEqual(e["result_count"], 0)
|
|
197
|
+
|
|
198
|
+
def test_no_match_in_transcript_does_not_fire(self):
|
|
199
|
+
# Transcript turns share NO terms with the auth-flow query.
|
|
200
|
+
transcript = self._write_transcript([
|
|
201
|
+
_nested_line("user", "what should we cook for dinner tonight"),
|
|
202
|
+
_nested_line("assistant", "maybe a pasta with roasted vegetables"),
|
|
203
|
+
])
|
|
204
|
+
client = _Client(bank_results={"own-bank": []})
|
|
205
|
+
context = self._run(client, transcript_path=transcript)
|
|
206
|
+
self.assertIsNone(context)
|
|
207
|
+
e = self._read_log()[0]
|
|
208
|
+
self.assertFalse(e["transcript_fallback"])
|
|
209
|
+
self.assertEqual(e["transcript_fallback_turns"], 0)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class DoesNotFireWhenFactLayerNotEmpty(_Harness):
|
|
213
|
+
def test_bank_returned_memories_suppresses_fallback(self):
|
|
214
|
+
transcript = self._write_transcript([
|
|
215
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
216
|
+
])
|
|
217
|
+
client = _Client(bank_results={"own-bank": [_memory("auth uses PKCE", "m1")]})
|
|
218
|
+
context = self._run(client, transcript_path=transcript)
|
|
219
|
+
self.assertIsNotNone(context)
|
|
220
|
+
self.assertIn("<hindsight_memories>", context)
|
|
221
|
+
self.assertNotIn("<hindsight_transcript_fallback>", context)
|
|
222
|
+
e = self._read_log()[0]
|
|
223
|
+
self.assertFalse(e["transcript_fallback"])
|
|
224
|
+
self.assertEqual(e["result_count"], 1)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class DoesNotFireWhenDeadlineHit(_Harness):
|
|
228
|
+
def test_timed_out_bank_suppresses_fallback(self):
|
|
229
|
+
transcript = self._write_transcript([
|
|
230
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
231
|
+
])
|
|
232
|
+
# Own bank returns zero fast; an extra bank sleeps past the 0.5s
|
|
233
|
+
# deadline → abandoned → deadline_hit True even though results are empty.
|
|
234
|
+
client = _Client(
|
|
235
|
+
bank_sleep={"own-bank": 0.0, "slow-bank": 3.0},
|
|
236
|
+
bank_results={"own-bank": [], "slow-bank": []},
|
|
237
|
+
)
|
|
238
|
+
context = self._run(
|
|
239
|
+
client,
|
|
240
|
+
transcript_path=transcript,
|
|
241
|
+
config_extra={
|
|
242
|
+
"recallAdditionalBanks": ["slow-bank"],
|
|
243
|
+
"recallParallelDeadlineSeconds": 0.5,
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
self.assertIsNone(context, "fallback must not fire when a bank timed out")
|
|
247
|
+
e = self._read_log()[0]
|
|
248
|
+
self.assertTrue(e["deadline_hit"])
|
|
249
|
+
self.assertFalse(e["transcript_fallback"])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class DoesNotFireWhenBankErrored(_Harness):
|
|
253
|
+
"""PR8 gating-hole fix — a HARD (non-timeout) bank error contributes zero
|
|
254
|
+
results with deadline_hit False. The fallback must be suppressed: an
|
|
255
|
+
unreachable fact layer is NOT a genuinely empty one, and firing every turn
|
|
256
|
+
during an outage would flood telemetry and mislabel the cause."""
|
|
257
|
+
|
|
258
|
+
def test_errored_bank_suppresses_fallback_parallel(self):
|
|
259
|
+
transcript = self._write_transcript([
|
|
260
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
261
|
+
])
|
|
262
|
+
# Own bank raises a hard, non-timeout error (connection refused).
|
|
263
|
+
client = _Client(
|
|
264
|
+
bank_errors={"own-bank": ConnectionRefusedError("connection refused")},
|
|
265
|
+
)
|
|
266
|
+
context = self._run(client, transcript_path=transcript)
|
|
267
|
+
self.assertIsNone(context, "fallback must not fire when a bank hard-errored")
|
|
268
|
+
e = self._read_log()[0]
|
|
269
|
+
self.assertTrue(e["bank_errored"])
|
|
270
|
+
self.assertFalse(e["deadline_hit"])
|
|
271
|
+
self.assertFalse(e["transcript_fallback"])
|
|
272
|
+
self.assertEqual(e["result_count"], 0)
|
|
273
|
+
|
|
274
|
+
def test_errored_bank_suppresses_fallback_serial(self):
|
|
275
|
+
transcript = self._write_transcript([
|
|
276
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
277
|
+
])
|
|
278
|
+
client = _Client(
|
|
279
|
+
bank_errors={"own-bank": RuntimeError("upstream 503 service unavailable")},
|
|
280
|
+
)
|
|
281
|
+
context = self._run(
|
|
282
|
+
client,
|
|
283
|
+
transcript_path=transcript,
|
|
284
|
+
config_extra={"recallParallel": False},
|
|
285
|
+
)
|
|
286
|
+
self.assertIsNone(context, "fallback must not fire when a bank hard-errored")
|
|
287
|
+
e = self._read_log()[0]
|
|
288
|
+
self.assertEqual(e["recall_mode"], "serial")
|
|
289
|
+
self.assertTrue(e["bank_errored"])
|
|
290
|
+
self.assertFalse(e["deadline_hit"])
|
|
291
|
+
self.assertFalse(e["transcript_fallback"])
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class FiresInSerialMode(_Harness):
|
|
295
|
+
"""Rollback (serial) mode gates the fallback identically to parallel mode:
|
|
296
|
+
on an all-zero, no-timeout, no-error turn it fires; the mode is only a
|
|
297
|
+
latency lever, not a behavioural one."""
|
|
298
|
+
|
|
299
|
+
def test_fires_when_all_banks_zero_serial(self):
|
|
300
|
+
transcript = self._write_transcript([
|
|
301
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
302
|
+
_nested_line("assistant", "agreed, the auth flow now uses PKCE"),
|
|
303
|
+
])
|
|
304
|
+
client = _Client(bank_results={"own-bank": []})
|
|
305
|
+
context = self._run(
|
|
306
|
+
client,
|
|
307
|
+
transcript_path=transcript,
|
|
308
|
+
config_extra={"recallParallel": False},
|
|
309
|
+
)
|
|
310
|
+
self.assertIsNotNone(context, "fallback should fire in serial mode too")
|
|
311
|
+
self.assertIn("<hindsight_transcript_fallback>", context)
|
|
312
|
+
self.assertIn("PKCE", context)
|
|
313
|
+
e = self._read_log()[0]
|
|
314
|
+
self.assertEqual(e["recall_mode"], "serial")
|
|
315
|
+
self.assertTrue(e["transcript_fallback"])
|
|
316
|
+
self.assertFalse(e["deadline_hit"])
|
|
317
|
+
self.assertFalse(e["bank_errored"])
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class BoundsHold(_Harness):
|
|
321
|
+
def test_byte_bound_only_reads_tail(self):
|
|
322
|
+
# A query-matching line in the HEAD, padded past the byte window, must
|
|
323
|
+
# not be surfaced; a recent matching line must be.
|
|
324
|
+
pad = _nested_line("assistant", "filler noise " * 40)
|
|
325
|
+
head = _nested_line("user", "the OLD auth flow decision was OAuth1")
|
|
326
|
+
lines = [head] + [pad] * 60 + [
|
|
327
|
+
_nested_line("user", "the NEW auth flow uses PKCE tokens"),
|
|
328
|
+
]
|
|
329
|
+
transcript = self._write_transcript(lines)
|
|
330
|
+
client = _Client(bank_results={"own-bank": []})
|
|
331
|
+
# 4 KiB tail — comfortably smaller than the head+padding above it.
|
|
332
|
+
context = self._run(
|
|
333
|
+
client,
|
|
334
|
+
transcript_path=transcript,
|
|
335
|
+
config_extra={"recallTranscriptFallbackMaxBytes": 4096},
|
|
336
|
+
)
|
|
337
|
+
self.assertIsNotNone(context)
|
|
338
|
+
self.assertIn("PKCE tokens", context)
|
|
339
|
+
self.assertNotIn("OAuth1", context)
|
|
340
|
+
e = self._read_log()[0]
|
|
341
|
+
self.assertTrue(e["transcript_fallback"])
|
|
342
|
+
self.assertLessEqual(e["transcript_fallback_bytes_read"], 4096)
|
|
343
|
+
|
|
344
|
+
def test_turn_bound_caps_matched_turns(self):
|
|
345
|
+
# Ten matching turns, but MaxTurns=3 → at most 3 injected.
|
|
346
|
+
lines = [
|
|
347
|
+
_nested_line("user", f"auth flow revision number {i}")
|
|
348
|
+
for i in range(10)
|
|
349
|
+
]
|
|
350
|
+
transcript = self._write_transcript(lines)
|
|
351
|
+
client = _Client(bank_results={"own-bank": []})
|
|
352
|
+
context = self._run(
|
|
353
|
+
client,
|
|
354
|
+
transcript_path=transcript,
|
|
355
|
+
config_extra={"recallTranscriptFallbackMaxTurns": 3},
|
|
356
|
+
)
|
|
357
|
+
self.assertIsNotNone(context)
|
|
358
|
+
e = self._read_log()[0]
|
|
359
|
+
self.assertEqual(e["transcript_fallback_turns"], 3)
|
|
360
|
+
|
|
361
|
+
def test_char_bound_truncates(self):
|
|
362
|
+
big = "auth flow " + ("detail " * 400) # ~2800 chars, one turn
|
|
363
|
+
transcript = self._write_transcript([_nested_line("user", big)])
|
|
364
|
+
client = _Client(bank_results={"own-bank": []})
|
|
365
|
+
context = self._run(
|
|
366
|
+
client,
|
|
367
|
+
transcript_path=transcript,
|
|
368
|
+
config_extra={"recallTranscriptFallbackMaxChars": 300},
|
|
369
|
+
)
|
|
370
|
+
self.assertIsNotNone(context)
|
|
371
|
+
e = self._read_log()[0]
|
|
372
|
+
self.assertTrue(e["transcript_fallback"])
|
|
373
|
+
self.assertTrue(e["transcript_fallback_truncated"])
|
|
374
|
+
# The injected excerpt characters are bounded by the cap. The emitted
|
|
375
|
+
# block is: the fixed wrapper tags + preamble, plus the excerpt whose
|
|
376
|
+
# length is capped at MaxChars (300). We compute the EXACT wrapper
|
|
377
|
+
# overhead so a cap breach (e.g. the off-by-one that made a truncated
|
|
378
|
+
# entry cap+1 chars) would actually fail this assertion — the previous
|
|
379
|
+
# `+ 200` slack was loose enough to swallow such a breach.
|
|
380
|
+
wrapper = len(
|
|
381
|
+
"<hindsight_transcript_fallback>\n"
|
|
382
|
+
+ recall._FALLBACK_BLOCK_PREAMBLE
|
|
383
|
+
+ "\n\n"
|
|
384
|
+
+ "\n</hindsight_transcript_fallback>"
|
|
385
|
+
)
|
|
386
|
+
self.assertLessEqual(e["transcript_fallback_chars"], 300 + wrapper)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class ConfigGate(_Harness):
|
|
390
|
+
def test_disabled_by_config(self):
|
|
391
|
+
transcript = self._write_transcript([
|
|
392
|
+
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
393
|
+
])
|
|
394
|
+
client = _Client(bank_results={"own-bank": []})
|
|
395
|
+
context = self._run(
|
|
396
|
+
client,
|
|
397
|
+
transcript_path=transcript,
|
|
398
|
+
config_extra={"recallTranscriptFallback": False},
|
|
399
|
+
)
|
|
400
|
+
self.assertIsNone(context)
|
|
401
|
+
e = self._read_log()[0]
|
|
402
|
+
self.assertFalse(e["transcript_fallback"])
|
|
403
|
+
|
|
404
|
+
def test_no_transcript_path_is_safe(self):
|
|
405
|
+
client = _Client(bank_results={"own-bank": []})
|
|
406
|
+
context = self._run(client, transcript_path="")
|
|
407
|
+
self.assertIsNone(context)
|
|
408
|
+
e = self._read_log()[0]
|
|
409
|
+
self.assertFalse(e["transcript_fallback"])
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
unittest.main()
|
|
@@ -341,6 +341,55 @@ class TestWatermark(DurabilityTestBase):
|
|
|
341
341
|
self.assertIsNone(watermark.load("wm2"))
|
|
342
342
|
|
|
343
343
|
|
|
344
|
+
class TestSidechainSkip(DurabilityTestBase):
|
|
345
|
+
"""PR5 review finding 1: reconcile must NOT treat a sub-agent sidechain
|
|
346
|
+
transcript as a pseudo-session. The recursive glob matches
|
|
347
|
+
<session>/subagents/agent-<id>.jsonl; without the guard reconcile would
|
|
348
|
+
retain it at boot with an untagged, disjoint-namespace document (permanent
|
|
349
|
+
duplicate of subagent_retain.py's tagged retain, at full recall weight,
|
|
350
|
+
bypassing the volume gate)."""
|
|
351
|
+
|
|
352
|
+
def _write_sidechain(self, path, n_turns):
|
|
353
|
+
lines = []
|
|
354
|
+
for i in range(n_turns):
|
|
355
|
+
lines.append(json.dumps({
|
|
356
|
+
"type": "user", "isSidechain": True, "agentId": "af5",
|
|
357
|
+
"uuid": f"su{i}", "message": {"role": "user", "content": f"sc turn {i}"},
|
|
358
|
+
}))
|
|
359
|
+
lines.append(json.dumps({
|
|
360
|
+
"type": "assistant", "isSidechain": True, "agentId": "af5",
|
|
361
|
+
"uuid": f"sa{i}", "message": {"role": "assistant", "content": f"sc did {i}"},
|
|
362
|
+
}))
|
|
363
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
364
|
+
f.write("\n".join(lines))
|
|
365
|
+
|
|
366
|
+
def test_reconcile_skips_sidechain_transcript(self):
|
|
367
|
+
session = "sessX"
|
|
368
|
+
# A real parent session with un-committed turns (must still reconcile).
|
|
369
|
+
parent = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
370
|
+
_write_transcript(parent, 4, session_prefix=session)
|
|
371
|
+
# A sidechain under <session>/subagents/ that the recursive glob matches.
|
|
372
|
+
sub_dir = os.path.join(self.transcripts, session, "subagents")
|
|
373
|
+
os.makedirs(sub_dir)
|
|
374
|
+
sc = os.path.join(sub_dir, "agent-af5.jsonl")
|
|
375
|
+
self._write_sidechain(sc, 5)
|
|
376
|
+
|
|
377
|
+
hook = {"session_id": session, "transcript_path": parent, "cwd": "/x"}
|
|
378
|
+
summary = reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
379
|
+
|
|
380
|
+
# The sidechain was recognised and skipped ...
|
|
381
|
+
self.assertGreaterEqual(summary["skipped_sidechain"], 1)
|
|
382
|
+
blob = self.daemon.content_blob()
|
|
383
|
+
# ... none of its content was retained ...
|
|
384
|
+
self.assertNotIn("sc turn", blob)
|
|
385
|
+
self.assertNotIn("sc did", blob)
|
|
386
|
+
# ... and no document carries the untagged agent-<id> namespace.
|
|
387
|
+
self.assertFalse(any(did.startswith("agent-af5") for did in self.daemon.docs))
|
|
388
|
+
# ... while the genuine parent session WAS reconciled.
|
|
389
|
+
for i in range(4):
|
|
390
|
+
self.assertIn(f"user turn {i}", blob)
|
|
391
|
+
|
|
392
|
+
|
|
344
393
|
def _stdin(obj):
|
|
345
394
|
import io
|
|
346
395
|
return io.StringIO(json.dumps(obj))
|