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,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))