switchroom 0.19.28 → 0.19.30

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.
@@ -12,6 +12,8 @@ single deterministic id), 6 (watermark monotonicity + uuid-not-found), 7
12
12
  bound ENQUEUES the remainder).
13
13
  """
14
14
 
15
+ import contextlib
16
+ import io
15
17
  import json
16
18
  import os
17
19
  import shutil
@@ -41,12 +43,16 @@ class FakeDaemon:
41
43
 
42
44
  def __init__(self):
43
45
  self.docs = {} # document_id -> {content, metadata, async}
46
+ # switchroom: the observation_scopes kwarg each POST carried.
47
+ self.observation_scopes_seen = []
44
48
  self.posts = [] # [(document_id, async_processing)]
45
49
  self.fail = False
46
50
  self.drop_async = False
47
51
 
48
52
  def retain(self, bank_id, content, document_id="conversation", context=None,
49
- metadata=None, tags=None, timeout=15, async_processing=True):
53
+ metadata=None, tags=None, timeout=15, async_processing=True,
54
+ observation_scopes=None):
55
+ self.observation_scopes_seen.append(observation_scopes)
50
56
  self.posts.append((document_id, async_processing))
51
57
  if self.fail:
52
58
  raise RuntimeError("simulated daemon failure")
@@ -390,6 +396,159 @@ class TestSidechainSkip(DurabilityTestBase):
390
396
  self.assertIn(f"user turn {i}", blob)
391
397
 
392
398
 
399
+ class TestObservationScopes(DurabilityTestBase):
400
+ """switchroom — the per-row observation scope on the LIVE retain paths.
401
+
402
+ Asserts the OUTCOME on the wire: what scope the Stop hook, the boot
403
+ reconciler, and the pending-queue drain actually POST with. Each of those
404
+ is a separate hand-enumerated kwarg list, so each needs its own pin — a
405
+ miss on any one silently drops that path back to per-tag scopes.
406
+ """
407
+
408
+ def _hook(self, session="scopesess", n=5):
409
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
410
+ _write_transcript(tpath, n, session_prefix=session)
411
+ return {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
412
+
413
+ def _set_scope(self, value):
414
+ os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
415
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
416
+
417
+ # -- default: nothing changes -------------------------------------------
418
+ def test_unconfigured_stop_retain_posts_no_scope(self):
419
+ hook = self._hook("plainsess")
420
+ with mock.patch("retain.increment_turn_count", return_value=3), \
421
+ mock.patch("sys.stdin", _stdin(hook)):
422
+ retain.main()
423
+ self.assertTrue(self.daemon.observation_scopes_seen)
424
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
425
+
426
+ # -- Stop hook (retain.py) ----------------------------------------------
427
+ def test_configured_stop_retain_posts_the_scope(self):
428
+ self._set_scope("shared")
429
+ hook = self._hook("livesess")
430
+ with mock.patch("retain.increment_turn_count", return_value=3), \
431
+ mock.patch("sys.stdin", _stdin(hook)):
432
+ retain.main()
433
+ self.assertTrue(self.daemon.observation_scopes_seen)
434
+ self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
435
+
436
+ # -- boot reconciler (reconcile_tail.py) --------------------------------
437
+ def test_configured_boot_reconcile_posts_the_scope(self):
438
+ self._set_scope("shared")
439
+ hook = self._hook("reconsess")
440
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
441
+ self.assertTrue(self.daemon.observation_scopes_seen)
442
+ self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
443
+
444
+ # -- durability: the scope survives the pending queue --------------------
445
+ def test_scope_survives_failure_enqueue_and_drain(self):
446
+ self._set_scope("shared")
447
+ hook = self._hook("failsess")
448
+
449
+ # The daemon refuses, so the Stop retain is ENQUEUED rather than landed.
450
+ self.daemon.fail = True
451
+ with mock.patch("retain.increment_turn_count", return_value=3), \
452
+ mock.patch("sys.stdin", _stdin(hook)):
453
+ retain.main()
454
+ entries = self._pending_entries()
455
+ self.assertEqual(len(entries), 1)
456
+ _path, entry = entries[0]
457
+ # The queued entry carries the scope, so the drain does not have to
458
+ # re-resolve config that may have changed since.
459
+ self.assertEqual(entry["observation_scopes"], "shared")
460
+
461
+ # Drain it hours later: it lands in the SAME scope.
462
+ import drain_pending
463
+ seen = []
464
+
465
+ class _Client:
466
+ def __init__(self, *a, **kw):
467
+ pass
468
+
469
+ def retain(self, **kwargs):
470
+ seen.append(kwargs.get("observation_scopes"))
471
+ return {"ok": True}
472
+
473
+ with mock.patch.object(drain_pending, "HindsightClient", _Client):
474
+ drain_pending._retry_one(entry, timeout=15)
475
+ self.assertEqual(seen, ["shared"])
476
+
477
+ # -- a typo must never destroy a memory ---------------------------------
478
+ #
479
+ # These are the regression tests for the worst bug the validation
480
+ # introduced. `build_retain_payload` used to RAISE on an off-list value.
481
+ # It is called from `run_retain`, which `retain.main` calls WITHOUT a
482
+ # try/except before its `pending_enqueue` — so the raise unwound past the
483
+ # enqueue: nothing POSTed, nothing queued, watermark not advanced, the turn
484
+ # gone. `session_end.py` caught it but had no payload to queue, and
485
+ # `session_start.py` swallowed the same raise into `debug_log` and aborted
486
+ # the reconcile loop that would have re-derived it. Every producer lost its
487
+ # memory outright, permanently and silently, for as long as the bad value
488
+ # sat in the config — switchroom #3244's shape, which this feature cites.
489
+ #
490
+ # A misconfigured scope is recoverable. A deleted turn is not. So a bad
491
+ # value degrades to the PRE-FEATURE behaviour (no field on the wire, the
492
+ # engine's own default scope) and is shouted about on stderr.
493
+
494
+ def test_a_typo_does_not_destroy_the_live_stop_retain(self):
495
+ self._set_scope("shred") # not "shared"
496
+ hook = self._hook("typolivesess")
497
+ err = io.StringIO()
498
+ with mock.patch("retain.increment_turn_count", return_value=3), \
499
+ mock.patch("sys.stdin", _stdin(hook)), \
500
+ contextlib.redirect_stderr(err):
501
+ retain.main()
502
+
503
+ # OUTCOME 1: the memory LANDED. Not "an exception was caught" — the
504
+ # turns are in the bank.
505
+ # (chunked mode with retainEveryNTurns=3 slices the last 3 turns)
506
+ blob = self.daemon.content_blob()
507
+ self.assertIn("user turn 4", blob)
508
+ # OUTCOME 2: at the engine's own default scope (field omitted), which
509
+ # is exactly what an unconfigured agent does.
510
+ self.assertTrue(self.daemon.observation_scopes_seen)
511
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
512
+ # OUTCOME 3: and it was loud. A silent downgrade would be the original
513
+ # "typo ignored forever" defect.
514
+ self.assertIn("shred", err.getvalue())
515
+ self.assertIn("observation_scopes", err.getvalue())
516
+
517
+ def test_a_typo_does_not_destroy_a_FAILED_stop_retain(self):
518
+ # The reviewer's reproduction: with the raise in place this enqueued 0
519
+ # entries and left the watermark unmoved, so the turn was lost for good.
520
+ self._set_scope("shred")
521
+ hook = self._hook("typofailsess")
522
+
523
+ self.daemon.fail = True
524
+ with mock.patch("retain.increment_turn_count", return_value=3), \
525
+ mock.patch("sys.stdin", _stdin(hook)), \
526
+ contextlib.redirect_stderr(io.StringIO()):
527
+ retain.main()
528
+
529
+ # OUTCOME: the payload was still built and still ENQUEUED, so the next
530
+ # SessionStart drain replays it. This is the assertion that matters —
531
+ # the memory has a durable on-disk copy.
532
+ entries = self._pending_entries()
533
+ self.assertEqual(len(entries), 1)
534
+ _path, entry = entries[0]
535
+ self.assertIn("user turn 4", entry["content"])
536
+ self.assertIsNone(entry["observation_scopes"])
537
+
538
+ def test_a_typo_does_not_destroy_the_boot_reconcile(self):
539
+ # session_start.py swallows a reconcile raise into debug_log AND aborts
540
+ # the loop on the first bad payload, so the recovery path that would
541
+ # re-derive lost turns did nothing either.
542
+ self._set_scope("shred")
543
+ hook = self._hook("typoreconsess")
544
+ with contextlib.redirect_stderr(io.StringIO()):
545
+ reconcile_tail.reconcile(self._config(), hook_input=hook)
546
+ blob = self.daemon.content_blob()
547
+ self.assertIn("user turn 0", blob)
548
+ self.assertTrue(self.daemon.observation_scopes_seen)
549
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
550
+
551
+
393
552
  def _stdin(obj):
394
553
  import io
395
554
  return io.StringIO(json.dumps(obj))
@@ -289,7 +289,7 @@ class BoundedRead(unittest.TestCase):
289
289
 
290
290
 
291
291
  class RunSubagentRetain(unittest.TestCase):
292
- def _run(self, hook_input):
292
+ def _run(self, hook_input, config_extra=None):
293
293
  captured = {}
294
294
 
295
295
  class _Client:
@@ -300,7 +300,8 @@ class RunSubagentRetain(unittest.TestCase):
300
300
  captured.update(kwargs)
301
301
  return {"ok": True}
302
302
 
303
- with mock.patch("subagent_retain.load_config", return_value=dict(CONFIG)), \
303
+ config = dict(CONFIG, **(config_extra or {}))
304
+ with mock.patch("subagent_retain.load_config", return_value=config), \
304
305
  mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
305
306
  mock.patch("subagent_retain.ensure_bank_mission"), \
306
307
  mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
@@ -336,6 +337,43 @@ class RunSubagentRetain(unittest.TestCase):
336
337
  self.assertEqual(captured["context"], "claude-code-sidechain")
337
338
  self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
338
339
 
340
+ def test_sidechain_retain_omits_the_scope_when_unconfigured(self):
341
+ # switchroom — default behaviour must be byte-identical: the sidechain
342
+ # POST carries no scope, so the engine default stands.
343
+ with tempfile.TemporaryDirectory() as d:
344
+ sc = os.path.join(d, "agent-af5.jsonl")
345
+ _write_sidechain(sc, 8, chars_per_msg=400)
346
+ hook_input = {
347
+ "session_id": "parentsess",
348
+ "agent_id": "af5",
349
+ "agent_transcript_path": sc,
350
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
351
+ "cwd": d,
352
+ }
353
+ result, captured = self._run(hook_input)
354
+ self.assertEqual(result["status"], "ok")
355
+ self.assertIsNone(captured["observation_scopes"])
356
+
357
+ def test_sidechain_retain_posts_the_configured_scope(self):
358
+ # switchroom — sidechain retains are their own hand-enumerated kwarg
359
+ # list; without this pin they would silently keep per-tag scopes while
360
+ # the parent session's retains moved to the shared one.
361
+ with tempfile.TemporaryDirectory() as d:
362
+ sc = os.path.join(d, "agent-af5.jsonl")
363
+ _write_sidechain(sc, 8, chars_per_msg=400)
364
+ hook_input = {
365
+ "session_id": "parentsess",
366
+ "agent_id": "af5",
367
+ "agent_transcript_path": sc,
368
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
369
+ "cwd": d,
370
+ }
371
+ result, captured = self._run(
372
+ hook_input, config_extra={"observationScopes": "shared"}
373
+ )
374
+ self.assertEqual(result["status"], "ok")
375
+ self.assertEqual(captured["observation_scopes"], "shared")
376
+
339
377
  def test_document_id_is_stable_across_refires(self):
340
378
  with tempfile.TemporaryDirectory() as d:
341
379
  sc = os.path.join(d, "agent-af5.jsonl")