switchroom 0.19.38 → 0.19.40
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 +10 -1
- package/dist/auth-broker/index.js +15 -6
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +1141 -566
- package/dist/host-control/main.js +220 -8
- package/dist/vault/approvals/kernel-server.js +15 -6
- package/dist/vault/broker/server.js +15 -6
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/dist/bridge/bridge.js +8 -0
- package/telegram-plugin/dist/gateway/gateway.js +450 -206
- package/telegram-plugin/dist/server.js +8 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +3 -3
- package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +277 -18
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
- package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
- package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
- package/vendor/hindsight-memory/scripts/retain.py +19 -15
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
|
@@ -38,6 +38,8 @@ import retain # noqa: E402
|
|
|
38
38
|
from lib.client import HindsightClient # noqa: E402
|
|
39
39
|
from lib.config import ( # noqa: E402
|
|
40
40
|
OBSERVATION_SCOPES_VALUES,
|
|
41
|
+
OBSERVATION_SCOPE_STRATEGIES,
|
|
42
|
+
compute_observation_scopes,
|
|
41
43
|
load_config,
|
|
42
44
|
resolve_observation_scopes,
|
|
43
45
|
)
|
|
@@ -134,6 +136,18 @@ class ConfigResolution(unittest.TestCase):
|
|
|
134
136
|
clear=True):
|
|
135
137
|
self.assertEqual(load_config().get("observationScopes"), "shared")
|
|
136
138
|
|
|
139
|
+
def test_strategy_default_is_curated(self):
|
|
140
|
+
# switchroom ships curated ON out of the box — a fresh install with no
|
|
141
|
+
# settings.json key and no env override still gets the curated scope.
|
|
142
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
143
|
+
self.assertEqual(load_config().get("observationScopeStrategy"), "curated")
|
|
144
|
+
|
|
145
|
+
def test_strategy_env_override_opts_out(self):
|
|
146
|
+
with mock.patch.dict(
|
|
147
|
+
os.environ, {"HINDSIGHT_OBSERVATION_SCOPE_STRATEGY": "combined"}, clear=True
|
|
148
|
+
):
|
|
149
|
+
self.assertEqual(load_config().get("observationScopeStrategy"), "combined")
|
|
150
|
+
|
|
137
151
|
|
|
138
152
|
class PayloadBuild(unittest.TestCase):
|
|
139
153
|
"""``build_retain_payload`` is the single producer for every retain path."""
|
|
@@ -147,10 +161,33 @@ class PayloadBuild(unittest.TestCase):
|
|
|
147
161
|
bank_id="bank", api_url="http://fake", api_token=None,
|
|
148
162
|
)["payload"]
|
|
149
163
|
|
|
150
|
-
def
|
|
151
|
-
|
|
164
|
+
def test_payload_omits_scope_when_opted_out(self):
|
|
165
|
+
# Opt-out (strategy=combined) is byte-identical to the pre-feature body:
|
|
166
|
+
# no scope on the payload, so the engine default stands.
|
|
167
|
+
self.assertIsNone(
|
|
168
|
+
self._build({"observationScopeStrategy": "combined"})["observation_scopes"]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def test_payload_curates_the_scope_by_default(self):
|
|
172
|
+
# No strategy key at all → the shipped default (curated) fires, so a
|
|
173
|
+
# scope IS carried. A retain carrying only volatile/no stable tags
|
|
174
|
+
# curates down to the bank-wide "shared" scope.
|
|
175
|
+
self.assertEqual(self._build({})["observation_scopes"], "shared")
|
|
176
|
+
|
|
177
|
+
def test_payload_curates_stable_tags_into_an_explicit_scope(self):
|
|
178
|
+
# A stable semantic tag survives onto the consolidation scope as an
|
|
179
|
+
# explicit list-of-lists, while a volatile session-id tag is stripped.
|
|
180
|
+
payload = self._build(
|
|
181
|
+
{"retainTags": ["team:acme", "{session_id}"]}
|
|
182
|
+
)
|
|
183
|
+
# {session_id} → "sess" (not a UUID / parent_session:*), so it is NOT
|
|
184
|
+
# volatile here and rides the scope alongside the stable tag.
|
|
185
|
+
self.assertEqual(
|
|
186
|
+
sorted(payload["observation_scopes"][0]), ["sess", "team:acme"]
|
|
187
|
+
)
|
|
152
188
|
|
|
153
|
-
def
|
|
189
|
+
def test_payload_carries_the_manually_pinned_scope(self):
|
|
190
|
+
# A hand-pinned observationScopes still wins over the strategy.
|
|
154
191
|
self.assertEqual(
|
|
155
192
|
self._build({"observationScopes": "shared"})["observation_scopes"],
|
|
156
193
|
"shared",
|
|
@@ -205,6 +242,16 @@ class DrainOfQueuedEntries(unittest.TestCase):
|
|
|
205
242
|
drain_pending._retry_one(entry, timeout=15)
|
|
206
243
|
self.assertEqual(self.calls[0]["observation_scopes"], "shared")
|
|
207
244
|
|
|
245
|
+
def test_curated_list_scope_survives_the_queue_round_trip(self):
|
|
246
|
+
# The curated default emits a list[list[str]] scope. A retain that fails
|
|
247
|
+
# inline and drains hours later must land in the SAME curated scope, so
|
|
248
|
+
# the list value has to survive JSON on disk and reach client.retain
|
|
249
|
+
# intact — otherwise a retried turn shards away from its inline siblings.
|
|
250
|
+
scope = [["agent_type:worker", "sidechain"]]
|
|
251
|
+
entry = json.loads(json.dumps(dict(self._LEGACY, observation_scopes=scope)))
|
|
252
|
+
drain_pending._retry_one(entry, timeout=15)
|
|
253
|
+
self.assertEqual(self.calls[0]["observation_scopes"], scope)
|
|
254
|
+
|
|
208
255
|
|
|
209
256
|
class ValueValidation(unittest.TestCase):
|
|
210
257
|
"""An off-list scope must not reach the wire — and must not cost a memory.
|
|
@@ -321,5 +368,147 @@ class ValueValidation(unittest.TestCase):
|
|
|
321
368
|
resolve_observation_scopes(load_config())
|
|
322
369
|
|
|
323
370
|
|
|
371
|
+
class ComputeCuratedScopes(unittest.TestCase):
|
|
372
|
+
"""``compute_observation_scopes`` — the curated-default resolver, asserted as
|
|
373
|
+
exact (input tags, config) → emitted ``observation_scopes`` value mappings.
|
|
374
|
+
|
|
375
|
+
This is the load-bearing new behaviour: which tags survive onto the
|
|
376
|
+
consolidation scope and which are stripped as volatile provenance. The
|
|
377
|
+
function NEVER raises — a bad config degrades the scope, never the turn.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
_UUID = "0291c461-864d-4284-b2b3-3fba9bf3142c"
|
|
381
|
+
|
|
382
|
+
def _compute(self, tags, **config):
|
|
383
|
+
return compute_observation_scopes(tags, config)
|
|
384
|
+
|
|
385
|
+
# --- default (curated) ------------------------------------------------
|
|
386
|
+
def test_curated_strips_parent_session_keeps_stable(self):
|
|
387
|
+
value, err = self._compute(["parent_session:abc", "sidechain", "agent_type:worker"])
|
|
388
|
+
self.assertIsNone(err)
|
|
389
|
+
self.assertEqual(value, [["agent_type:worker", "sidechain"]]) # sorted, stripped
|
|
390
|
+
|
|
391
|
+
def test_curated_strips_bare_uuid_session_tag(self):
|
|
392
|
+
value, err = self._compute([self._UUID, "lesson"])
|
|
393
|
+
self.assertIsNone(err)
|
|
394
|
+
self.assertEqual(value, [["lesson"]])
|
|
395
|
+
|
|
396
|
+
def test_curated_strips_sidechain_sub_session_tag(self):
|
|
397
|
+
# The dominant observation path: subagent_retain sets
|
|
398
|
+
# `sub_session_id = f"{session_id}-sub-{agent_id}"`, and `retainTags:
|
|
399
|
+
# ["{session_id}"]` resolves to `<parent-uuid>-sub-<agent_id>`. That is
|
|
400
|
+
# per-invocation-unique, so if it survives into the scope every sidechain
|
|
401
|
+
# retain gets its own island and never dedups. It must be treated as
|
|
402
|
+
# volatile (like the bare UUID it derives from) and stripped, while the
|
|
403
|
+
# stable semantic tags stay in scope.
|
|
404
|
+
sub_tag = f"{self._UUID}-sub-af5fba739c0ee6b38"
|
|
405
|
+
value, err = self._compute([sub_tag, "sidechain", "agent_type:worker"])
|
|
406
|
+
self.assertIsNone(err)
|
|
407
|
+
self.assertEqual(value, [["agent_type:worker", "sidechain"]])
|
|
408
|
+
|
|
409
|
+
def test_curated_all_volatile_falls_back_to_shared(self):
|
|
410
|
+
# A retain tagged ONLY with volatile provenance has no stable scope, so
|
|
411
|
+
# it pools into the one bank-wide untagged scope instead of an island.
|
|
412
|
+
value, err = self._compute([self._UUID, "parent_session:xyz"])
|
|
413
|
+
self.assertIsNone(err)
|
|
414
|
+
self.assertEqual(value, "shared")
|
|
415
|
+
|
|
416
|
+
def test_curated_no_tags_is_shared(self):
|
|
417
|
+
self.assertEqual(self._compute([])[0], "shared")
|
|
418
|
+
self.assertEqual(self._compute(None)[0], "shared")
|
|
419
|
+
|
|
420
|
+
def test_curated_scope_is_deterministically_sorted(self):
|
|
421
|
+
# Same tag set in any order → identical scope (so it dedups, not shards).
|
|
422
|
+
a, _ = self._compute(["z:1", "a:2", "m:3"])
|
|
423
|
+
b, _ = self._compute(["m:3", "z:1", "a:2"])
|
|
424
|
+
self.assertEqual(a, b)
|
|
425
|
+
self.assertEqual(a, [["a:2", "m:3", "z:1"]])
|
|
426
|
+
|
|
427
|
+
def test_curated_dedups_repeated_tags(self):
|
|
428
|
+
value, _ = self._compute(["lesson", "lesson", "sidechain"])
|
|
429
|
+
self.assertEqual(value, [["lesson", "sidechain"]])
|
|
430
|
+
|
|
431
|
+
# --- opt-out ----------------------------------------------------------
|
|
432
|
+
def test_combined_emits_no_scope(self):
|
|
433
|
+
self.assertEqual(
|
|
434
|
+
self._compute(["lesson"], observationScopeStrategy="combined"), (None, None)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def test_off_emits_no_scope(self):
|
|
438
|
+
self.assertEqual(
|
|
439
|
+
self._compute(["lesson"], observationScopeStrategy="off"), (None, None)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def test_shared_strategy_is_uniform(self):
|
|
443
|
+
value, err = self._compute(["lesson", "sidechain"], observationScopeStrategy="shared")
|
|
444
|
+
self.assertIsNone(err)
|
|
445
|
+
self.assertEqual(value, "shared")
|
|
446
|
+
|
|
447
|
+
# --- precedence / robustness -----------------------------------------
|
|
448
|
+
def test_manual_pin_wins_over_strategy(self):
|
|
449
|
+
# An operator who pinned per_tag keeps it even though curated is default.
|
|
450
|
+
value, err = self._compute(
|
|
451
|
+
["lesson"], observationScopes="per_tag", observationScopeStrategy="curated"
|
|
452
|
+
)
|
|
453
|
+
self.assertIsNone(err)
|
|
454
|
+
self.assertEqual(value, "per_tag")
|
|
455
|
+
|
|
456
|
+
def test_manual_pin_typo_degrades_to_none_with_error_not_curated(self):
|
|
457
|
+
# A typo'd pin must NOT silently fall through to curated — it degrades to
|
|
458
|
+
# the engine default (None) and carries an error, exactly like the
|
|
459
|
+
# pre-strategy behaviour, so the misconfiguration stays visible.
|
|
460
|
+
value, err = self._compute(["lesson"], observationScopes="shred")
|
|
461
|
+
self.assertIsNone(value)
|
|
462
|
+
self.assertIsNotNone(err)
|
|
463
|
+
self.assertIn("shred", err)
|
|
464
|
+
|
|
465
|
+
def test_unknown_strategy_degrades_to_curated_with_error(self):
|
|
466
|
+
value, err = self._compute(["lesson"], observationScopeStrategy="curatd")
|
|
467
|
+
self.assertEqual(value, [["lesson"]])
|
|
468
|
+
self.assertIsNotNone(err)
|
|
469
|
+
self.assertIn("curatd", err)
|
|
470
|
+
for s in OBSERVATION_SCOPE_STRATEGIES:
|
|
471
|
+
self.assertIn(s, err)
|
|
472
|
+
|
|
473
|
+
def test_empty_strategy_string_is_curated(self):
|
|
474
|
+
# An empty export hands authority back to the default, same idiom as
|
|
475
|
+
# the bare-string scope path.
|
|
476
|
+
self.assertEqual(self._compute(["lesson"], observationScopeStrategy="")[0], [["lesson"]])
|
|
477
|
+
self.assertEqual(self._compute(["lesson"], observationScopeStrategy=" ")[0], [["lesson"]])
|
|
478
|
+
|
|
479
|
+
def test_strategy_is_case_insensitive(self):
|
|
480
|
+
self.assertEqual(
|
|
481
|
+
self._compute(["lesson"], observationScopeStrategy="COMBINED"), (None, None)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def test_custom_volatile_patterns_override_defaults(self):
|
|
485
|
+
# An operator can declare a bank-specific provenance tag volatile.
|
|
486
|
+
value, err = self._compute(
|
|
487
|
+
["run:42", "lesson"],
|
|
488
|
+
observationScopeVolatilePatterns=[r"^run:"],
|
|
489
|
+
)
|
|
490
|
+
self.assertIsNone(err)
|
|
491
|
+
self.assertEqual(value, [["lesson"]])
|
|
492
|
+
|
|
493
|
+
def test_bad_volatile_pattern_is_skipped_not_fatal(self):
|
|
494
|
+
# A malformed regex must not kill the retain — it is dropped and the
|
|
495
|
+
# remaining (valid) matchers still curate.
|
|
496
|
+
err_out = io.StringIO()
|
|
497
|
+
with contextlib.redirect_stderr(err_out):
|
|
498
|
+
value, err = self._compute(
|
|
499
|
+
[self._UUID, "lesson"],
|
|
500
|
+
observationScopeVolatilePatterns=["(", r"^parent_session:",
|
|
501
|
+
r"^[0-9a-fA-F-]{36}$"],
|
|
502
|
+
)
|
|
503
|
+
self.assertIsNone(err)
|
|
504
|
+
self.assertEqual(value, [["lesson"]])
|
|
505
|
+
|
|
506
|
+
def test_never_raises_on_junk_tags(self):
|
|
507
|
+
# Non-string tags in the list must be ignored, not explode.
|
|
508
|
+
value, err = self._compute(["lesson", None, 42, "", "sidechain"])
|
|
509
|
+
self.assertIsNone(err)
|
|
510
|
+
self.assertEqual(value, [["lesson", "sidechain"]])
|
|
511
|
+
|
|
512
|
+
|
|
324
513
|
if __name__ == "__main__": # pragma: no cover
|
|
325
514
|
unittest.main()
|
|
@@ -414,8 +414,15 @@ class TestObservationScopes(DurabilityTestBase):
|
|
|
414
414
|
os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
|
|
415
415
|
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
417
|
+
def _set_strategy(self, value):
|
|
418
|
+
os.environ["HINDSIGHT_OBSERVATION_SCOPE_STRATEGY"] = value
|
|
419
|
+
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY", None)
|
|
420
|
+
|
|
421
|
+
# -- opt-out: byte-identical to pre-feature -----------------------------
|
|
422
|
+
def test_opted_out_stop_retain_posts_no_scope(self):
|
|
423
|
+
# observationScopeStrategy=combined restores the pre-feature body: no
|
|
424
|
+
# scope on the wire so the engine default stands.
|
|
425
|
+
self._set_strategy("combined")
|
|
419
426
|
hook = self._hook("plainsess")
|
|
420
427
|
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
421
428
|
mock.patch("sys.stdin", _stdin(hook)):
|
|
@@ -423,6 +430,15 @@ class TestObservationScopes(DurabilityTestBase):
|
|
|
423
430
|
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
424
431
|
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
425
432
|
|
|
433
|
+
# -- default ON: a curated scope reaches the Stop-hook POST --------------
|
|
434
|
+
def test_default_stop_retain_posts_a_curated_scope(self):
|
|
435
|
+
hook = self._hook("plainsess")
|
|
436
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
437
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
438
|
+
retain.main()
|
|
439
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
440
|
+
self.assertTrue(all(s is not None for s in self.daemon.observation_scopes_seen))
|
|
441
|
+
|
|
426
442
|
# -- Stop hook (retain.py) ----------------------------------------------
|
|
427
443
|
def test_configured_stop_retain_posts_the_scope(self):
|
|
428
444
|
self._set_scope("shared")
|
|
@@ -339,9 +339,10 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
339
339
|
self.assertEqual(captured["context"], "claude-code-sidechain")
|
|
340
340
|
self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
|
|
341
341
|
|
|
342
|
-
def
|
|
343
|
-
# switchroom —
|
|
344
|
-
#
|
|
342
|
+
def test_sidechain_retain_omits_the_scope_when_opted_out(self):
|
|
343
|
+
# switchroom — opt-out (observationScopeStrategy=combined) must be
|
|
344
|
+
# byte-identical to the pre-feature body: the sidechain POST carries no
|
|
345
|
+
# scope, so the engine default stands.
|
|
345
346
|
with tempfile.TemporaryDirectory() as d:
|
|
346
347
|
sc = os.path.join(d, "agent-af5.jsonl")
|
|
347
348
|
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
@@ -352,10 +353,62 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
352
353
|
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
353
354
|
"cwd": d,
|
|
354
355
|
}
|
|
355
|
-
result, captured = self._run(
|
|
356
|
+
result, captured = self._run(
|
|
357
|
+
hook_input, config_extra={"observationScopeStrategy": "combined"}
|
|
358
|
+
)
|
|
356
359
|
self.assertEqual(result["status"], "ok")
|
|
357
360
|
self.assertIsNone(captured["observation_scopes"])
|
|
358
361
|
|
|
362
|
+
def test_sidechain_retain_curates_the_scope_by_default(self):
|
|
363
|
+
# switchroom default ON: every VOLATILE per-session provenance tag is
|
|
364
|
+
# STRIPPED from the consolidation scope so sidechain observations dedup
|
|
365
|
+
# across parent sessions, while the stable semantic tags (`sidechain`,
|
|
366
|
+
# `agent_type:*`) are KEPT on the scope (so recall's sidechain:0.8
|
|
367
|
+
# demotion still fires on the observation).
|
|
368
|
+
#
|
|
369
|
+
# Two volatile tags must be stripped, and the second is the one the
|
|
370
|
+
# Fable review caught leaking:
|
|
371
|
+
# 1. `parent_session:<uuid>` — the explicit parent link.
|
|
372
|
+
# 2. `<uuid>-sub-<agent_id>` — what `retainTags: ["{session_id}"]`
|
|
373
|
+
# resolves to on the sidechain path, since subagent_retain sets
|
|
374
|
+
# `sub_session_id = f"{session_id}-sub-{agent_id}"`. This is
|
|
375
|
+
# per-invocation-unique; if it survives into the scope, sidechain
|
|
376
|
+
# retains (the dominant observation volume) never dedup and the
|
|
377
|
+
# feature silently fails on its primary path.
|
|
378
|
+
#
|
|
379
|
+
# The session_id is a real RFC-4122 UUID here (not a synthetic slug)
|
|
380
|
+
# precisely because the strip is anchored on the UUID shape — a slug
|
|
381
|
+
# would not exercise the volatile pattern and the test would pass on the
|
|
382
|
+
# pre-fix code. Assert EXACT scope equality so the test fails on the bug
|
|
383
|
+
# it guards, not merely on a membership check.
|
|
384
|
+
session_id = "a1b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d"
|
|
385
|
+
with tempfile.TemporaryDirectory() as d:
|
|
386
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
387
|
+
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
388
|
+
hook_input = {
|
|
389
|
+
"session_id": session_id,
|
|
390
|
+
"agent_id": "af5",
|
|
391
|
+
"agent_type": "worker",
|
|
392
|
+
"agent_transcript_path": sc,
|
|
393
|
+
"transcript_path": os.path.join(d, f"{session_id}.jsonl"),
|
|
394
|
+
"cwd": d,
|
|
395
|
+
}
|
|
396
|
+
result, captured = self._run(hook_input)
|
|
397
|
+
self.assertEqual(result["status"], "ok")
|
|
398
|
+
scope = captured["observation_scopes"]
|
|
399
|
+
# EXACT curated scope: only the stable semantic tags survive, sorted.
|
|
400
|
+
self.assertEqual(scope, [["agent_type:worker", "sidechain"]])
|
|
401
|
+
|
|
402
|
+
# And the specific volatile tags are provably gone from the scope.
|
|
403
|
+
scope_tags = {t for group in scope for t in group}
|
|
404
|
+
sub_session_tag = f"{session_id}-sub-af5"
|
|
405
|
+
self.assertNotIn(f"parent_session:{session_id}", scope_tags)
|
|
406
|
+
self.assertNotIn(sub_session_tag, scope_tags)
|
|
407
|
+
|
|
408
|
+
# The source-fact tags are untouched — the stripped tags stay queryable.
|
|
409
|
+
self.assertIn(f"parent_session:{session_id}", captured["tags"])
|
|
410
|
+
self.assertIn(sub_session_tag, captured["tags"])
|
|
411
|
+
|
|
359
412
|
def test_sidechain_retain_posts_the_configured_scope(self):
|
|
360
413
|
# switchroom — sidechain retains are their own hand-enumerated kwarg
|
|
361
414
|
# list; without this pin they would silently keep per-tag scopes while
|