switchroom 0.19.28 → 0.19.29

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.
@@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
28
  from lib import watermark
29
29
  from lib.bank import derive_bank_id, ensure_bank_mission
30
30
  from lib.client import HindsightClient
31
- from lib.config import debug_log, load_config
31
+ from lib.config import classify_observation_scopes, debug_log, load_config
32
32
  from lib.content import (
33
33
  prepare_retention_transcript,
34
34
  slice_last_turns_by_user_boundary,
@@ -266,6 +266,14 @@ def build_retain_payload(
266
266
 
267
267
  Returns ``{payload, document_id, message_count, last_uuid, ordered_uuids,
268
268
  transcript}`` or ``None`` when the slice formats to nothing.
269
+
270
+ NEVER raises on a bad ``observationScopes``. Every retain producer builds
271
+ its payload here, so this seam sees the typo — but it is also the seam the
272
+ memory itself is made at, and a config typo must not be able to destroy
273
+ one. An off-list value is dropped from the payload (so the engine's own
274
+ default stands, exactly as before this feature existed) and shouted about
275
+ on stderr; the memory is still built, still POSTed, still queued on
276
+ failure. See ``lib.config.classify_observation_scopes``.
269
277
  """
270
278
  retain_roles = config.get("retainRoles", ["user", "assistant"])
271
279
  include_tool_calls = config.get("retainToolCalls", True)
@@ -359,6 +367,36 @@ def build_retain_payload(
359
367
  except Exception:
360
368
  pass
361
369
 
370
+ # Per-row observation scope (switchroom). None unless the operator set
371
+ # memory.observation_scopes — and a None is dropped at the wire by
372
+ # HindsightClient._retain_one, so the default request body is unchanged.
373
+ # Carried ON THE PAYLOAD so it survives the pending-retains queue: a retain
374
+ # that fails and drains hours later must land in the SAME scope it would
375
+ # have landed in inline.
376
+ #
377
+ # CLASSIFIED, NOT VALIDATED. This is the one seam every retain producer
378
+ # funnels through, which makes it the tempting place to reject a typo — and
379
+ # the worst possible place to raise from. Raising here does not "fail the
380
+ # retain", it DELETES the turn: the exception propagates out of run_retain,
381
+ # past retain.main's pending_enqueue (so nothing is queued and the
382
+ # watermark never advances), session_end.py catches it with no payload to
383
+ # queue, and session_start.py's reconciler swallows it into debug_log and
384
+ # aborts the loop that would have re-derived it. Every producer loses its
385
+ # memory outright, silently, for as long as the bad value sits in the
386
+ # config — switchroom #3244's exact shape.
387
+ #
388
+ # So a bad value degrades to the PRE-FEATURE behaviour (field omitted, the
389
+ # engine's own default scope stands) and is shouted about on stderr. Wrong
390
+ # scope is recoverable; a lost turn is not.
391
+ scope, scope_error = classify_observation_scopes(config)
392
+ if scope_error:
393
+ print(
394
+ f"[Hindsight] observation_scopes IGNORED for this retain: {scope_error} "
395
+ "The memory is being retained at the engine's default scope rather "
396
+ "than dropped — fix the value, then `switchroom apply` and restart "
397
+ "the agent.",
398
+ file=sys.stderr,
399
+ )
362
400
  payload = {
363
401
  "api_url": api_url,
364
402
  "api_token": api_token,
@@ -368,6 +406,7 @@ def build_retain_payload(
368
406
  "context": config.get("retainContext", "claude-code"),
369
407
  "metadata": metadata,
370
408
  "tags": tags,
409
+ "observation_scopes": scope,
371
410
  }
372
411
  return {
373
412
  "payload": payload,
@@ -573,6 +612,7 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
573
612
  tags=payload["tags"],
574
613
  timeout=15,
575
614
  async_processing=False,
615
+ observation_scopes=payload.get("observation_scopes"),
576
616
  )
577
617
  except Exception as e:
578
618
  print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
@@ -443,6 +443,7 @@ def run_subagent_retain(hook_input: dict) -> dict:
443
443
  tags=payload["tags"],
444
444
  timeout=15,
445
445
  async_processing=False,
446
+ observation_scopes=payload.get("observation_scopes"),
446
447
  )
447
448
  except Exception as e:
448
449
  print(f"[Hindsight] Sidechain retain failed: {e}", file=sys.stderr)
@@ -35,6 +35,8 @@ class FakeDaemon:
35
35
 
36
36
  def __init__(self):
37
37
  self.docs = {} # document_id -> {content, ...}
38
+ # switchroom: the observation_scopes kwarg each POST carried.
39
+ self.observation_scopes_seen = []
38
40
  self.posts = [] # [(document_id, async_processing)]
39
41
  self.mission_patches = [] # set_bank_mission calls (must stay empty)
40
42
  self.fail = False
@@ -42,7 +44,9 @@ class FakeDaemon:
42
44
  self._inflight = 0
43
45
 
44
46
  def retain(self, bank_id, content, document_id="conversation", context=None,
45
- metadata=None, tags=None, timeout=15, async_processing=True):
47
+ metadata=None, tags=None, timeout=15, async_processing=True,
48
+ observation_scopes=None):
49
+ self.observation_scopes_seen.append(observation_scopes)
46
50
  self._inflight += 1
47
51
  self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
48
52
  try:
@@ -167,6 +171,24 @@ class TestBackfill(BackfillTestBase):
167
171
  self.assertTrue(self.daemon.posts)
168
172
  self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
169
173
 
174
+ # -- switchroom: per-row observation scope on the backfill path ---------
175
+ def test_backfill_omits_the_scope_when_unconfigured(self):
176
+ self._transcript("clerk", "sess-plain", 4)
177
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
178
+ self.assertTrue(self.daemon.observation_scopes_seen)
179
+ self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
180
+
181
+ def test_backfill_posts_the_configured_scope(self):
182
+ # The backfill enumerates its own retain kwargs; a miss here would
183
+ # scatter every recovered historical slice into per-tag scopes while
184
+ # live retains pooled into the shared one.
185
+ os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = "shared"
186
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
187
+ self._transcript("clerk", "sess-scoped", 4)
188
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
189
+ self.assertTrue(self.daemon.observation_scopes_seen)
190
+ self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
191
+
170
192
  # -- Dedups against a pre-existing document with the same deterministic id
171
193
  def test_dedups_against_preexisting_document_id(self):
172
194
  path = self._transcript("clerk", "sess-dup", 3)
@@ -53,12 +53,16 @@ class FakeDaemon:
53
53
  def __init__(self):
54
54
  self.docs = {}
55
55
  self.posts = []
56
+ # switchroom: the observation_scopes kwarg each POST carried.
57
+ self.observation_scopes_seen = []
56
58
  self.max_inflight_seen = 0
57
59
  self._inflight = 0
58
60
  self.membership_error = False
59
61
 
60
62
  def retain(self, bank_id, content, document_id="conversation", context=None,
61
- metadata=None, tags=None, timeout=15, async_processing=True):
63
+ metadata=None, tags=None, timeout=15, async_processing=True,
64
+ observation_scopes=None):
65
+ self.observation_scopes_seen.append(observation_scopes)
62
66
  self._inflight += 1
63
67
  self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
64
68
  try:
@@ -0,0 +1,325 @@
1
+ """Switchroom — per-row ``observation_scopes`` plumbing on the retain path.
2
+
3
+ Hindsight stores an ``observation_scopes`` field per retained row.
4
+ ``"shared"`` makes consolidation write that item's observations into ONE
5
+ global untagged scope instead of a scope per tag. The plugin could not send
6
+ the field at all before this change, so a bank could never be pooled.
7
+
8
+ The load-bearing properties, each asserted as an OUTCOME on the wire body or
9
+ on the kwargs a callsite hands ``client.retain()``:
10
+
11
+ 1. **Unset is byte-identical to before.** With no config the key is ABSENT
12
+ from the POST body — not present-and-null — so the engine default stands.
13
+ 2. **Set reaches the wire**, on every part of a split retain.
14
+ 3. **It survives the pending queue.** The scope is carried on the payload,
15
+ so a retain that fails now and drains hours later lands in the same scope
16
+ it would have landed in inline.
17
+ 4. **Old queue entries still drain.** Entries written by a pre-feature build
18
+ are on disk right now and carry no such key; the drain must read them
19
+ with ``.get`` and post ``None``, never raise.
20
+
21
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
22
+ """
23
+
24
+ import contextlib
25
+ import io
26
+ import json
27
+ import os
28
+ import sys
29
+ import unittest
30
+ from unittest import mock
31
+
32
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
33
+ if SCRIPTS_DIR not in sys.path:
34
+ sys.path.insert(0, SCRIPTS_DIR)
35
+
36
+ import drain_pending # noqa: E402
37
+ import retain # noqa: E402
38
+ from lib.client import HindsightClient # noqa: E402
39
+ from lib.config import ( # noqa: E402
40
+ OBSERVATION_SCOPES_VALUES,
41
+ load_config,
42
+ resolve_observation_scopes,
43
+ )
44
+ from lib.retain_split import retain_content_limit # noqa: E402
45
+
46
+
47
+ class _RecordingClient(HindsightClient):
48
+ """Captures the request bodies instead of putting them on a socket."""
49
+
50
+ def __init__(self, *a, **kw):
51
+ super().__init__(*a, **kw)
52
+ self.bodies = []
53
+
54
+ def _request(self, method, path, body=None, timeout=30):
55
+ self.bodies.append(body)
56
+ return {"ok": True}
57
+
58
+
59
+ def _transcript(n_turns: int) -> list:
60
+ out = []
61
+ for i in range(n_turns):
62
+ out.append({"role": "user", "content": f"user turn {i}", "uuid": f"u{i}"})
63
+ out.append({"role": "assistant", "content": f"assistant turn {i}", "uuid": f"a{i}"})
64
+ return out
65
+
66
+
67
+ class WireBody(unittest.TestCase):
68
+ """What actually goes on the wire."""
69
+
70
+ def setUp(self):
71
+ self.client = _RecordingClient("http://hindsight.invalid")
72
+
73
+ def test_unset_omits_the_key_entirely(self):
74
+ self.client.retain("bank", "some transcript", document_id="doc")
75
+ item = self.client.bodies[0]["items"][0]
76
+ # Not `is None` — ABSENT. A null would be a value the engine has to
77
+ # interpret; the pre-feature body simply had no such key.
78
+ self.assertNotIn("observation_scopes", item)
79
+
80
+ def test_explicit_none_omits_the_key_entirely(self):
81
+ # The default config value is None and every callsite forwards it, so
82
+ # the None path is the one the whole fleet takes.
83
+ self.client.retain(
84
+ "bank", "some transcript", document_id="doc", observation_scopes=None
85
+ )
86
+ self.assertNotIn("observation_scopes", self.client.bodies[0]["items"][0])
87
+
88
+ def test_unset_body_is_identical_to_a_pre_feature_body(self):
89
+ self.client.retain(
90
+ "bank", "t", document_id="doc", context="claude-code",
91
+ metadata={"m": "1"}, tags=["x"],
92
+ )
93
+ self.assertEqual(
94
+ self.client.bodies[0],
95
+ {
96
+ "items": [{
97
+ "content": "t",
98
+ "document_id": "doc",
99
+ "metadata": {"m": "1"},
100
+ "context": "claude-code",
101
+ "tags": ["x"],
102
+ }],
103
+ "async": True,
104
+ },
105
+ )
106
+
107
+ def test_set_value_reaches_the_item(self):
108
+ self.client.retain(
109
+ "bank", "some transcript", document_id="doc", observation_scopes="shared"
110
+ )
111
+ self.assertEqual(
112
+ self.client.bodies[0]["items"][0]["observation_scopes"], "shared"
113
+ )
114
+
115
+ def test_every_part_of_a_split_retain_carries_it(self):
116
+ # A split that lands parts in DIFFERENT scopes would silently shard one
117
+ # memory across scopes — the exact drift this pins.
118
+ big = "z" * (retain_content_limit() * 3)
119
+ self.client.retain("bank", big, document_id="doc", observation_scopes="shared")
120
+ self.assertGreater(len(self.client.bodies), 1)
121
+ for body in self.client.bodies:
122
+ self.assertEqual(body["items"][0]["observation_scopes"], "shared")
123
+
124
+
125
+ class ConfigResolution(unittest.TestCase):
126
+ """``observationScopes`` default + the HINDSIGHT_OBSERVATION_SCOPES env."""
127
+
128
+ def test_default_is_none(self):
129
+ with mock.patch.dict(os.environ, {}, clear=True):
130
+ self.assertIsNone(load_config().get("observationScopes"))
131
+
132
+ def test_env_override_sets_it(self):
133
+ with mock.patch.dict(os.environ, {"HINDSIGHT_OBSERVATION_SCOPES": "shared"},
134
+ clear=True):
135
+ self.assertEqual(load_config().get("observationScopes"), "shared")
136
+
137
+
138
+ class PayloadBuild(unittest.TestCase):
139
+ """``build_retain_payload`` is the single producer for every retain path."""
140
+
141
+ _BASE = {"retainRoles": ["user", "assistant"], "retainContext": "claude-code"}
142
+
143
+ def _build(self, config_extra):
144
+ config = dict(self._BASE, **config_extra)
145
+ return retain.build_retain_payload(
146
+ config, "sess", _transcript(2), _transcript(2),
147
+ bank_id="bank", api_url="http://fake", api_token=None,
148
+ )["payload"]
149
+
150
+ def test_payload_carries_none_when_unconfigured(self):
151
+ self.assertIsNone(self._build({})["observation_scopes"])
152
+
153
+ def test_payload_carries_the_configured_scope(self):
154
+ self.assertEqual(
155
+ self._build({"observationScopes": "shared"})["observation_scopes"],
156
+ "shared",
157
+ )
158
+
159
+
160
+ class DrainOfQueuedEntries(unittest.TestCase):
161
+ """``drain_pending._retry_one`` — the durability path, over MIXED entries."""
162
+
163
+ def setUp(self):
164
+ self.calls = []
165
+ outer = self
166
+
167
+ class _Client:
168
+ def __init__(self, *a, **kw):
169
+ pass
170
+
171
+ def retain(self, **kwargs):
172
+ outer.calls.append(kwargs)
173
+ return {"ok": True}
174
+
175
+ self.patch = mock.patch.object(drain_pending, "HindsightClient", _Client)
176
+ self.patch.start()
177
+ self.addCleanup(self.patch.stop)
178
+
179
+ _LEGACY = {
180
+ "api_url": "http://fake",
181
+ "api_token": None,
182
+ "bank_id": "bank",
183
+ "document_id": "doc",
184
+ "content": "old transcript",
185
+ "context": "claude-code",
186
+ "metadata": {},
187
+ "tags": None,
188
+ }
189
+
190
+ def test_entry_written_before_the_feature_drains_with_none(self):
191
+ # These entries are on disk RIGHT NOW. A KeyError here would strand the
192
+ # last on-disk copy of a turn, which is the #3244 silent-loss shape.
193
+ drain_pending._retry_one(dict(self._LEGACY), timeout=15)
194
+ self.assertEqual(self.calls[0]["content"], "old transcript")
195
+ self.assertIsNone(self.calls[0]["observation_scopes"])
196
+
197
+ def test_entry_carrying_a_scope_drains_into_that_scope(self):
198
+ entry = dict(self._LEGACY, observation_scopes="shared")
199
+ drain_pending._retry_one(entry, timeout=15)
200
+ self.assertEqual(self.calls[0]["observation_scopes"], "shared")
201
+
202
+ def test_scope_survives_a_json_round_trip_through_the_queue_file(self):
203
+ # The queue is JSON on disk; the scope must come back out of it.
204
+ entry = json.loads(json.dumps(dict(self._LEGACY, observation_scopes="shared")))
205
+ drain_pending._retry_one(entry, timeout=15)
206
+ self.assertEqual(self.calls[0]["observation_scopes"], "shared")
207
+
208
+
209
+ class ValueValidation(unittest.TestCase):
210
+ """An off-list scope must not reach the wire — and must not cost a memory.
211
+
212
+ The value is invisible after the write: a typo would keep retaining
213
+ happily, the engine would apply its own default scope, and the damage
214
+ (a bank whose observations never merged) surfaces only much later. The
215
+ `memory.observation_scopes` zod enum is the primary gate, but it cannot
216
+ see a hand-edited settings.json or a raw HINDSIGHT_OBSERVATION_SCOPES
217
+ export — which is why this second gate exists here.
218
+
219
+ TWO different obligations, and they are not the same severity:
220
+
221
+ * `resolve_observation_scopes` is the strict VALIDATOR and RAISES. Callers
222
+ that can safely stop (a config check, a hand-run script) use it.
223
+ * the retain path uses the non-raising classifier, drops the bad field and
224
+ shouts. A misconfigured scope is recoverable; a deleted turn is not, and
225
+ raising at the build seam deleted turns — see
226
+ `test_a_typo_never_reaches_the_payload_BUT_the_memory_survives`.
227
+ """
228
+
229
+ _BASE = {"retainRoles": ["user", "assistant"], "retainContext": "claude-code"}
230
+
231
+ def _build(self, config_extra):
232
+ config = dict(self._BASE, **config_extra)
233
+ return retain.build_retain_payload(
234
+ config, "sess", _transcript(2), _transcript(2),
235
+ bank_id="bank", api_url="http://fake", api_token=None,
236
+ )["payload"]
237
+
238
+ def test_every_accepted_value_resolves_to_itself(self):
239
+ for value in OBSERVATION_SCOPES_VALUES:
240
+ with self.subTest(value=value):
241
+ self.assertEqual(
242
+ resolve_observation_scopes({"observationScopes": value}), value
243
+ )
244
+
245
+ def test_unset_and_empty_resolve_to_none(self):
246
+ # Empty is UNSET, not a typo — matches the plugin's "an empty export
247
+ # hands authority back to the config file" idiom.
248
+ self.assertIsNone(resolve_observation_scopes({}))
249
+ self.assertIsNone(resolve_observation_scopes({"observationScopes": None}))
250
+ self.assertIsNone(resolve_observation_scopes({"observationScopes": ""}))
251
+ self.assertIsNone(resolve_observation_scopes({"observationScopes": " "}))
252
+
253
+ def test_typo_raises_and_names_the_accepted_set(self):
254
+ with self.assertRaises(ValueError) as ctx:
255
+ resolve_observation_scopes({"observationScopes": "shred"})
256
+ msg = str(ctx.exception)
257
+ self.assertIn("shred", msg)
258
+ for value in OBSERVATION_SCOPES_VALUES:
259
+ self.assertIn(value, msg)
260
+
261
+ def test_wrong_case_raises(self):
262
+ with self.assertRaises(ValueError):
263
+ resolve_observation_scopes({"observationScopes": "Shared"})
264
+
265
+ def test_non_string_raises(self):
266
+ with self.assertRaises(ValueError):
267
+ resolve_observation_scopes({"observationScopes": ["shared"]})
268
+
269
+ def test_a_typo_never_reaches_the_payload_BUT_the_memory_survives(self):
270
+ # Two outcomes, and the second is the load-bearing one.
271
+ #
272
+ # (a) The bad value does not ride to the wire — `observation_scopes` is
273
+ # None, so `HindsightClient._retain_one` omits the key and the
274
+ # engine's own default scope stands. That is the pre-feature
275
+ # behaviour, and it is a *recoverable* misconfiguration.
276
+ #
277
+ # (b) The PAYLOAD IS STILL BUILT. `build_retain_payload` raising here
278
+ # was a far worse bug than the one it fixed: the raise unwound past
279
+ # `retain.main`'s `pending_enqueue`, so the turn was never POSTed,
280
+ # never queued and never re-derivable — permanent silent memory
281
+ # loss for as long as the typo sat in the config. A config typo
282
+ # must never be able to delete a memory. End-to-end coverage of
283
+ # the same guarantee lives in
284
+ # tests/test_reconcile_durability.py::TestObservationScopes.
285
+ payload = self._build({"observationScopes": "shred"})
286
+ self.assertIsNone(payload["observation_scopes"])
287
+ self.assertIn("user turn 0", payload["content"])
288
+ self.assertTrue(payload["document_id"])
289
+
290
+ def test_a_typo_never_reaches_the_wire(self):
291
+ client = _RecordingClient("http://fake")
292
+ payload = self._build({"observationScopes": "per-tag"}) # hyphen, not underscore
293
+ client.retain(
294
+ payload["bank_id"],
295
+ payload["content"],
296
+ document_id=payload["document_id"],
297
+ observation_scopes=payload["observation_scopes"],
298
+ )
299
+ self.assertEqual(len(client.bodies), 1)
300
+ # ABSENT, not null: the request body is the pre-feature one.
301
+ self.assertNotIn("observation_scopes", client.bodies[0]["items"][0])
302
+
303
+ def test_a_typo_is_shouted_about_rather_than_swallowed(self):
304
+ # A silent downgrade to the engine default is the ORIGINAL defect this
305
+ # feature exists to prevent. Degrading quietly would just reintroduce
306
+ # it, so the build seam must say so on stderr every time it fires.
307
+ err = io.StringIO()
308
+ with contextlib.redirect_stderr(err):
309
+ self._build({"observationScopes": "shred"})
310
+ msg = err.getvalue()
311
+ self.assertIn("shred", msg)
312
+ self.assertIn("observation_scopes", msg)
313
+ for value in OBSERVATION_SCOPES_VALUES:
314
+ self.assertIn(value, msg)
315
+
316
+ def test_env_var_typo_is_caught_too(self):
317
+ # The env path bypasses zod entirely, so this is the only gate on it.
318
+ with mock.patch.dict(os.environ, {"HINDSIGHT_OBSERVATION_SCOPES": "shred"},
319
+ clear=True):
320
+ with self.assertRaises(ValueError):
321
+ resolve_observation_scopes(load_config())
322
+
323
+
324
+ if __name__ == "__main__": # pragma: no cover
325
+ unittest.main()
@@ -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))