switchroom 0.19.41 → 0.19.42

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 (29) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +772 -234
  5. package/dist/host-control/main.js +23 -3
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/dist/gateway/gateway.js +97 -83
  10. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  11. package/telegram-plugin/gateway/gateway.ts +7 -7
  12. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  13. package/telegram-plugin/gateway/model-command.ts +21 -109
  14. package/telegram-plugin/gateway/stream-render.ts +57 -0
  15. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  16. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  17. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  18. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  19. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  20. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  21. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  22. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  23. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  27. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  29. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,135 @@
1
+ """Characterisation of ``recall._overlap_tokens`` — the transcript-fallback tokenizer.
2
+
3
+ WHY THIS FILE EXISTS
4
+ --------------------
5
+ ``_overlap_tokens`` is the keyword tokenizer behind the #3369 transcript-grep
6
+ recall fallback: a recent transcript turn is injected only if its token set
7
+ intersects the query's (``recall._build_transcript_fallback``). When the
8
+ lexical-overlap recall GATE was removed (#3761, commit e3b9d62f) the gate's
9
+ characterisation tests went with it — but ``_overlap_tokens`` itself stayed
10
+ load-bearing for the fallback. That left a real, shipped tokenizer with no
11
+ direct unit coverage: a change to its rules (letters-only vs alphanumeric, the
12
+ length floor, stopword handling) could silently move which transcript turns
13
+ recall can surface, and nothing would fail.
14
+
15
+ These tests pin the tokenizer's contract directly (#3777), including the #3578
16
+ decision that DIGITS CARRY SIGNAL (issue numbers, ports, versions). They assert
17
+ OUTCOMES of the tokenizer, not code paths, and each would fail on a real
18
+ regression of the documented behaviour.
19
+
20
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import unittest
26
+
27
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ if SCRIPTS_DIR not in sys.path:
29
+ sys.path.insert(0, SCRIPTS_DIR)
30
+
31
+ import recall # noqa: E402
32
+
33
+ tok = recall._overlap_tokens
34
+
35
+
36
+ class OverlapTokensCharacterisation(unittest.TestCase):
37
+ """Pure-function contract for ``recall._overlap_tokens`` (#3777)."""
38
+
39
+ # --- empty / non-string inputs -------------------------------------------
40
+
41
+ def test_empty_string_is_empty_set(self):
42
+ self.assertEqual(tok(""), set())
43
+
44
+ def test_none_is_empty_set(self):
45
+ self.assertEqual(tok(None), set())
46
+
47
+ def test_non_string_scalar_is_empty_set(self):
48
+ self.assertEqual(tok(123), set())
49
+
50
+ def test_non_string_container_is_empty_set(self):
51
+ self.assertEqual(tok(["deploy", "server"]), set())
52
+ self.assertEqual(tok({"deploy": 1}), set())
53
+
54
+ def test_whitespace_only_is_empty_set(self):
55
+ self.assertEqual(tok(" \t\n "), set())
56
+
57
+ # --- basic tokenisation ---------------------------------------------------
58
+
59
+ def test_simple_words_split_on_whitespace(self):
60
+ self.assertEqual(tok("deploy staging server"), {"deploy", "staging", "server"})
61
+
62
+ def test_lowercased(self):
63
+ self.assertEqual(tok("Deploy STAGING Server"), {"deploy", "staging", "server"})
64
+
65
+ def test_case_folds_to_a_single_token(self):
66
+ self.assertEqual(tok("DEPLOY deploy Deploy"), {"deploy"})
67
+
68
+ def test_duplicates_collapse_to_a_set(self):
69
+ self.assertEqual(tok("server server server"), {"server"})
70
+
71
+ def test_trailing_token_without_separator_is_flushed(self):
72
+ # The final run has no trailing delimiter; it must still be emitted.
73
+ self.assertEqual(tok("deploy server"), {"deploy", "server"})
74
+
75
+ # --- punctuation as separators -------------------------------------------
76
+
77
+ def test_punctuation_is_a_separator(self):
78
+ self.assertEqual(tok("deploy, the staging server!"), {"deploy", "staging", "server"})
79
+
80
+ def test_hyphen_splits_tokens(self):
81
+ self.assertEqual(tok("multi-word thing"), {"multi", "word", "thing"})
82
+
83
+ def test_underscore_splits_tokens(self):
84
+ # '_' is not alphanumeric, so it is a separator (documented behaviour).
85
+ self.assertEqual(tok("foo_bar baz"), {"foo", "bar", "baz"})
86
+
87
+ def test_dotted_version_splits_on_dots(self):
88
+ self.assertEqual(tok("v0.19.24"), {"v0", "19", "24"})
89
+
90
+ # --- length floor ---------------------------------------------------------
91
+
92
+ def test_single_alpha_char_is_dropped(self):
93
+ self.assertEqual(tok("x y z deploy"), {"deploy"})
94
+
95
+ def test_single_digit_char_is_dropped_as_noise(self):
96
+ # A lone digit is noise, not an identifier — dropped by the > 1 guard,
97
+ # exactly like a lone letter.
98
+ self.assertEqual(tok("python 9 angular"), {"python", "angular"})
99
+
100
+ def test_two_char_token_survives_the_floor(self):
101
+ self.assertEqual(tok("pr go"), {"pr", "go"})
102
+
103
+ # --- stopwords ------------------------------------------------------------
104
+
105
+ def test_stopwords_removed(self):
106
+ self.assertEqual(tok("the is a to of"), set())
107
+
108
+ def test_content_survives_with_stopwords_present(self):
109
+ self.assertEqual(tok("what is the deploy status"), {"deploy", "status"})
110
+
111
+ # --- #3578: digits carry signal ------------------------------------------
112
+
113
+ def test_pure_digit_issue_number_is_a_token(self):
114
+ self.assertEqual(tok("PR 3993"), {"pr", "3993"})
115
+
116
+ def test_hash_prefixed_issue_number_keeps_the_digits(self):
117
+ self.assertEqual(tok("#3541 shipped"), {"3541", "shipped"})
118
+
119
+ def test_colon_prefixed_port_keeps_the_digits(self):
120
+ self.assertEqual(tok(":9077 port"), {"9077", "port"})
121
+
122
+ def test_mixed_alphanumeric_run_stays_intact(self):
123
+ self.assertEqual(tok("abc123def"), {"abc123def"})
124
+
125
+ def test_identifier_overlap_between_query_and_transcript(self):
126
+ # The #3578 motivating case: a transcript turn whose only tie to the
127
+ # query is an issue number must now share a token (it did not before).
128
+ query = tok("did PR 3993 land and what did v0.19.24 change")
129
+ turn = tok("3993 shipped in 0.19.24 last night")
130
+ self.assertIn("3993", query & turn)
131
+ self.assertTrue(query & turn)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -115,21 +115,15 @@ def _run_main_with(client, prompt="what did we decide", config_extra=None):
115
115
  return json.loads(raw)["hookSpecificOutput"]["additionalContext"], raw
116
116
 
117
117
 
118
- class GateHelpersAreGoneTests(unittest.TestCase):
119
- """The gate's machinery must not exist to be re-wired by accident."""
120
-
121
- def test_gate_helpers_are_removed(self):
122
- self.assertFalse(hasattr(recall, "containment_overlap"))
123
- self.assertFalse(hasattr(recall, "_filter_by_overlap"))
124
-
125
- def test_tokenizer_survives_for_the_transcript_fallback(self):
126
- """`_overlap_tokens` is still load-bearing for
127
- `_build_transcript_fallback`'s keyword match — deleting it with the
128
- gate would silently break the #3369 fallback."""
129
- self.assertEqual(
130
- recall._overlap_tokens("Deploy the staging server!"),
131
- {"deploy", "staging", "server"},
132
- )
118
+ #
119
+ # NOTE (#3999): the former ``GateHelpersAreGoneTests`` hasattr guard
120
+ # (``assertFalse(hasattr(recall, "containment_overlap"))``) was dropped as
121
+ # redundant — a re-added-but-unwired helper does nothing, and a re-WIRED gate is
122
+ # already caught by the outcome assertions in ``NoLexicalDroppingIntegrationTests``
123
+ # below (which fail if any candidate is dropped for lexical reasons). Direct
124
+ # characterisation of the surviving ``_overlap_tokens`` tokenizer now lives in
125
+ # ``test_overlap_tokens.py`` (#3777), not in a hasattr check here.
126
+ #
133
127
 
134
128
 
135
129
  class NoLexicalDroppingIntegrationTests(unittest.TestCase):
@@ -151,17 +145,20 @@ class NoLexicalDroppingIntegrationTests(unittest.TestCase):
151
145
  self.assertIn("vegan dinner recipes", ctx)
152
146
 
153
147
  def test_identifier_only_match_survives(self):
154
- """The measured root cause: `_overlap_tokens` drops digits and
155
- 1-char tokens, so a memory whose ONLY relationship to the prompt is
156
- an identifier scored 0.0 and was always discarded. Assert directly
157
- that the tokenizer still cannot see the identifier, AND that the
158
- memory is injected anyway."""
148
+ """A memory whose ONLY tie to the prompt is an identifier survives.
149
+
150
+ This was the measured root cause of the removed gate: `_overlap_tokens`
151
+ dropped digits, so an identifier-only memory scored 0.0 and was always
152
+ discarded. Two things now hold: (a) #3578 gave the tokenizer digit
153
+ vision, so query and memory now DO share the identifier token; and (b)
154
+ with the gate gone, the memory is injected regardless of overlap."""
159
155
  query = PREAMBLE + "did PR #3541 land, and what did v0.19.24 change"
160
156
  mem = "#3541 shipped in 0.19.24"
161
- self.assertEqual(
157
+ # Post-#3578 the identifier is visible on both sides.
158
+ self.assertIn(
159
+ "3541",
162
160
  recall._overlap_tokens(query) & recall._overlap_tokens(mem),
163
- set(),
164
- "fixture drifted: it must share no ALPHABETIC token with the query",
161
+ "the shared identifier must now be a token on both sides (#3578)",
165
162
  )
166
163
  ctx, _ = _run_main_with(_FakeClient([_memory(mem, score=0.95)]), prompt=query)
167
164
  self.assertIsNotNone(ctx)
@@ -62,6 +62,7 @@ from lib.content import ( # noqa: E402
62
62
  BM25_STOPWORDS,
63
63
  _selectivity_score,
64
64
  common_english_words,
65
+ shape_recall_query,
65
66
  tokenize_for_bm25,
66
67
  )
67
68
 
@@ -469,5 +470,52 @@ class ShapingDoesNotMoveTheOverlapGate(_Harness):
469
470
  self.assertIn("did we decide about the", context)
470
471
 
471
472
 
473
+ class SingleCharSubjectSurvives(unittest.TestCase):
474
+ """#3766 — the ``len(t) > 1`` filter used to run BEFORE the stopword
475
+ fallback, dropping a single-char SUBJECT ('C', 'R', a single-digit version)
476
+ outright and then letting the fallback pick multi-char STOPWORDS instead. The
477
+ length guard now lives only in the fallback, so a single-char content word
478
+ survives while single-char stopwords ('a', 'i') are still removed.
479
+
480
+ These call ``shape_recall_query`` directly — the shaped string is what the
481
+ BM25 arm searches for, and a subject that vanishes from it is a subject the
482
+ recall cannot match on. Each assertion fails if the length filter moves back
483
+ ahead of the stopword filter.
484
+ """
485
+
486
+ def test_single_letter_subject_survives_among_stopwords(self):
487
+ # 'r' is the only content word; all the rest are stopwords. Pre-fix it
488
+ # was dropped and the fallback kept 'still'/'faster'/'python' but never
489
+ # 'r'; post-fix 'r' is a first-class term.
490
+ out = shape_recall_query("is R still faster than python", max_tokens=24)
491
+ self.assertIn("r", out.lower().split())
492
+
493
+ def test_subject_is_a_lone_single_char_and_all_else_stopwords(self):
494
+ # "what about C for this" — every other token is a stopword. The subject
495
+ # 'c' MUST be what searches; pre-fix the query came back as stopwords.
496
+ out = shape_recall_query("what about C for this", max_tokens=24)
497
+ terms = out.lower().split()
498
+ self.assertIn("c", terms)
499
+ for stop in ("what", "about", "for", "this"):
500
+ self.assertNotIn(stop, terms, f"stopword {stop!r} reached the wire over the subject")
501
+
502
+ def test_single_digit_version_survives(self):
503
+ # 'single-digit versions' (#3766): "python 9 or python 3".
504
+ out = shape_recall_query("python 9 or python 3", max_tokens=24)
505
+ terms = out.lower().split()
506
+ self.assertIn("9", terms)
507
+ self.assertIn("3", terms)
508
+
509
+ def test_single_char_stopwords_are_still_removed(self):
510
+ # The guard must not become "keep every single char": 'a' and 'i' are
511
+ # stopwords and must not survive just because they are one character.
512
+ out = shape_recall_query("a deploy i triggered", max_tokens=24)
513
+ terms = out.lower().split()
514
+ self.assertIn("deploy", terms)
515
+ self.assertIn("triggered", terms)
516
+ self.assertNotIn("a", terms)
517
+ self.assertNotIn("i", terms)
518
+
519
+
472
520
  if __name__ == "__main__": # pragma: no cover
473
521
  unittest.main()
@@ -7,6 +7,7 @@ failure. Stdlib-only; runs under ``python3 -m unittest discover tests/``.
7
7
  """
8
8
 
9
9
  import contextlib
10
+ import io
10
11
  import json
11
12
  import os
12
13
  import sys
@@ -255,13 +256,105 @@ class VolumeGate(unittest.TestCase):
255
256
  msgs = []
256
257
  for i in range(20):
257
258
  msgs.append({"role": "assistant", "content": "z" * 500})
258
- total = subagent_retain.non_tool_result_char_count(
259
+ total = subagent_retain.retained_text_char_count(
259
260
  msgs, stop_at=subagent_retain.MIN_NON_TOOL_RESULT_CHARS
260
261
  )
261
262
  self.assertGreaterEqual(total, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
262
263
  # Full sum would be 20*500=10000; short-circuit stops well before.
263
264
  self.assertLess(total, 10000)
264
265
 
266
+ def test_char_count_is_text_only_not_tool_use(self):
267
+ """#3994: the count measures only what the text-only retain path keeps.
268
+
269
+ A message with a small text block and a huge tool_use input must count
270
+ ONLY the text-block chars. The pre-#3994 metric added the tool_use
271
+ name+input serialized size; this asserts that contribution is gone, so a
272
+ revert goes RED.
273
+ """
274
+ msgs = [
275
+ {
276
+ "role": "assistant",
277
+ "content": [
278
+ {"type": "text", "text": "T" * 100},
279
+ {
280
+ "type": "tool_use",
281
+ "name": "Write",
282
+ "input": {"content": "Z" * 50_000},
283
+ },
284
+ ],
285
+ }
286
+ ]
287
+ self.assertEqual(subagent_retain.retained_text_char_count(msgs), 100)
288
+ # Alias preserves back-compat but returns the recalibrated value.
289
+ self.assertEqual(subagent_retain.non_tool_result_char_count(msgs), 100)
290
+
291
+ def test_tool_heavy_prose_light_fork_now_skips(self):
292
+ """#3994 headline: a fork that clears MIN_HUMAN_TURNS and emits a LOT of
293
+ tool traffic but almost no prose PASSED the old gate (tool_use chars) and
294
+ must now FAIL (text-only chars below the floor). The retained document
295
+ for such a fork was near-empty — the mismatch this recalibration closes.
296
+ """
297
+ msgs = []
298
+ for i in range(8): # >= MIN_HUMAN_TURNS genuine human turns
299
+ msgs.append({"role": "user", "content": "go"})
300
+ msgs.append(
301
+ {
302
+ "role": "assistant",
303
+ "content": [
304
+ # Pure tool traffic, no assistant prose. ~5 KB command
305
+ # each — bulk the OLD gate counted, the payload drops.
306
+ {"type": "tool_use", "name": "Bash",
307
+ "input": {"command": "true " + ("x" * 5000)}},
308
+ ],
309
+ }
310
+ )
311
+ msgs.append(
312
+ {"role": "user",
313
+ "content": [{"type": "tool_result", "tool_use_id": "t", "content": "Z" * 5000}]}
314
+ )
315
+ passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
316
+ self.assertGreaterEqual(turns, subagent_retain.MIN_HUMAN_TURNS)
317
+ self.assertFalse(passed, f"tool-heavy/prose-light fork must skip: chars={chars}")
318
+ self.assertLess(chars, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
319
+
320
+
321
+ class EmptyWindowSkipIsVisible(unittest.TestCase):
322
+ """#4001: the 'formatted to empty despite clearing the gate' skip must not be
323
+ debug-log-only (silent with debug off in shipped settings)."""
324
+
325
+ def _run_with_empty_build(self):
326
+ """Drive run_subagent_retain to the build_retain_payload==None branch."""
327
+ with tempfile.TemporaryDirectory() as d:
328
+ sc = os.path.join(d, "agent-af5.jsonl")
329
+ _write_sidechain(sc, 8, chars_per_msg=500) # clears the volume gate
330
+ hook_input = {
331
+ "session_id": "parentsess",
332
+ "agent_id": "af5",
333
+ "agent_type": "worker",
334
+ "agent_transcript_path": sc,
335
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
336
+ "cwd": d,
337
+ }
338
+ captured_err = io.StringIO()
339
+ with mock.patch("subagent_retain.load_config", return_value=dict(CONFIG)), \
340
+ mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
341
+ mock.patch("subagent_retain.ensure_bank_mission"), \
342
+ mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
343
+ mock.patch("subagent_retain.HindsightClient"), \
344
+ mock.patch("subagent_retain.inflight_lock", _lock_acquired), \
345
+ mock.patch("subagent_retain.build_retain_payload", return_value=None), \
346
+ mock.patch("sys.stderr", new=captured_err):
347
+ result = subagent_retain.run_subagent_retain(hook_input)
348
+ return result, captured_err.getvalue()
349
+
350
+ def test_empty_window_skip_emits_unconditional_stderr(self):
351
+ # debug is False (shipped default) — the skip must STILL be visible.
352
+ result, err = self._run_with_empty_build()
353
+ self.assertEqual(result["status"], "skipped")
354
+ self.assertEqual(result["reason"], "empty transcript after formatting")
355
+ self.assertIn("formatted to EMPTY", err)
356
+ self.assertIn("parentsess", err)
357
+
265
358
 
266
359
  class BoundedRead(unittest.TestCase):
267
360
  def test_read_transcript_max_bytes_reads_only_tail(self):
@@ -76,12 +76,24 @@ def _entry(role: str, content, uuid: str) -> str:
76
76
  )
77
77
 
78
78
 
79
+ # Per-turn assistant prose. Substantive text blocks so the fixture clears the
80
+ # RECALIBRATED (text-only) volume gate on genuine retainable prose — not on the
81
+ # tool_use volume the payload drops (#3994). ~180 chars each * 8 turns > the
82
+ # 2,000-char floor, all of it text the text-only path keeps.
83
+ TURN_PROSE = (
84
+ "Reasoning step {i}: ruled out the consolidator dedup theory — world and "
85
+ "experience facts never traverse the observation dedup path, so overlap "
86
+ "persists as extra rows. Recording the constraint before moving on."
87
+ )
88
+
89
+
79
90
  def _tool_heavy_sidechain(path: str, *, human_turns: int = 8) -> None:
80
- """A sidechain transcript that is mostly tool traffic plus one prose finding.
91
+ """A sidechain transcript that is mostly tool traffic plus real prose.
81
92
 
82
93
  Shaped to clear the volume gate (>= MIN_HUMAN_TURNS human turns and
83
- >= MIN_NON_TOOL_RESULT_CHARS of non-tool-result chars) so the retain
84
- actually happens the gate counts tool_use inputs, which this has in bulk.
94
+ >= MIN_NON_TOOL_RESULT_CHARS of RETAINED text) on the assistant PROSE — the
95
+ metric the recalibrated gate counts (#3994) while the bulk of the bytes on
96
+ disk is the tool traffic the text-only retain path drops.
85
97
  """
86
98
  lines = []
87
99
  for i in range(human_turns):
@@ -94,6 +106,10 @@ def _tool_heavy_sidechain(path: str, *, human_turns: int = 8) -> None:
94
106
  "type": "thinking",
95
107
  "thinking": "internal reasoning that must never be retained",
96
108
  },
109
+ {
110
+ "type": "text",
111
+ "text": TURN_PROSE.format(i=i),
112
+ },
97
113
  {
98
114
  "type": "tool_use",
99
115
  "id": f"tu_w{i}",
@@ -142,7 +158,15 @@ def _lock_acquired(blocking=False):
142
158
 
143
159
 
144
160
  def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
145
- """Drive run_subagent_retain with a stub client, returning (result, kwargs)."""
161
+ """Drive run_subagent_retain with a stub client.
162
+
163
+ Returns ``(result, captured_retain_kwargs, passed_config)`` where
164
+ ``passed_config`` is the EXACT dict object ``run_subagent_retain`` receives
165
+ from ``load_config`` — the object the production code actually holds and
166
+ could mutate. Tests assert on THAT object, never on the module-level
167
+ ``CONFIG`` template (which the code never sees; asserting on it is vacuous —
168
+ it passes even if the retainToolCalls-copy fix is deleted). See #3999.
169
+ """
146
170
  captured = {}
147
171
 
148
172
  class _Client:
@@ -153,7 +177,9 @@ def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
153
177
  captured.update(kwargs)
154
178
  return {"ok": True}
155
179
 
156
- config = dict(CONFIG, **(config_extra or {}))
180
+ # A FRESH copy per run — this is the object the code receives via
181
+ # load_config and is expected NOT to mutate.
182
+ passed_config = dict(CONFIG, **(config_extra or {}))
157
183
  hook_input = {
158
184
  "session_id": "parentsess",
159
185
  "agent_id": "af5",
@@ -162,14 +188,14 @@ def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
162
188
  "transcript_path": os.path.join(tmpdir, "parentsess.jsonl"),
163
189
  "cwd": tmpdir,
164
190
  }
165
- with mock.patch("subagent_retain.load_config", return_value=config), \
191
+ with mock.patch("subagent_retain.load_config", return_value=passed_config), \
166
192
  mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
167
193
  mock.patch("subagent_retain.ensure_bank_mission"), \
168
194
  mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
169
195
  mock.patch("subagent_retain.HindsightClient", _Client), \
170
196
  mock.patch("subagent_retain.inflight_lock", _lock_acquired):
171
197
  result = subagent_retain.run_subagent_retain(hook_input)
172
- return result, captured
198
+ return result, captured, passed_config
173
199
 
174
200
 
175
201
  class SidechainRetainsLearningsNotTools(unittest.TestCase):
@@ -180,7 +206,9 @@ class SidechainRetainsLearningsNotTools(unittest.TestCase):
180
206
  self.addCleanup(self._tmp.cleanup)
181
207
  self.sidechain = os.path.join(self._tmp.name, "agent-af5.jsonl")
182
208
  _tool_heavy_sidechain(self.sidechain)
183
- self.result, self.captured = _run_sidechain_retain(self.sidechain, self._tmp.name)
209
+ self.result, self.captured, self.passed_config = _run_sidechain_retain(
210
+ self.sidechain, self._tmp.name
211
+ )
184
212
  self.assertEqual(self.result["status"], "ok", self.result)
185
213
  self.content = self.captured["content"]
186
214
 
@@ -227,21 +255,46 @@ class SidechainRetainsLearningsNotTools(unittest.TestCase):
227
255
  )
228
256
 
229
257
  def test_operator_config_is_not_mutated(self):
230
- """The override lands on the sidechain COPY, not the parent's config."""
231
- self.assertIs(CONFIG["retainToolCalls"], True)
232
-
233
-
234
- class ProseFreeSidechainDegradesGracefully(unittest.TestCase):
235
- """The degenerate case a worker with NO assistant prose at all.
258
+ """The ``retainToolCalls = False`` override lands on the sidechain COPY
259
+ (``sub_config = dict(config)``), never on the config the hook received.
260
+
261
+ Asserts on ``passed_config`` — the EXACT object ``run_subagent_retain``
262
+ got from ``load_config`` and could have mutated in place — not on the
263
+ module-level ``CONFIG`` template. The old assertion (`CONFIG[...] is
264
+ True`) was vacuous: the harness always hands the code a fresh copy, so
265
+ ``CONFIG`` stays True even if the production copy is deleted and the code
266
+ mutates its input directly. This version goes RED in exactly that case.
267
+ """
268
+ self.assertEqual(
269
+ self.passed_config["retainToolCalls"],
270
+ True,
271
+ "run_subagent_retain mutated its caller's config (retainToolCalls "
272
+ "flipped to False in place) instead of overriding on a copy",
273
+ )
236
274
 
237
- Worth pinning because the volume gate counts ``tool_use`` inputs
238
- (``non_tool_result_char_count``) while the retained content no longer
239
- includes them, so gate and payload now measure different things. The
240
- reassuring outcome, verified here rather than assumed: it still retains
241
- cleanly, because the gate requires ``MIN_HUMAN_TURNS`` GENUINE human turns
242
- and each one is a plain-string user message that survives the text path. So
243
- ``build_retain_payload`` does not return None and nothing goes silently
244
- empty — while the tool traffic is still excluded.
275
+ def test_override_is_applied_on_the_copy_the_client_sees(self):
276
+ """The flip must still REACH the payload: the stub client's retained
277
+ content is on the text-only path (no tool bodies), which only happens if
278
+ the sidechain copy carried ``retainToolCalls = False``. Together with
279
+ the test above this pins BOTH halves copied AND applied — so deleting
280
+ either the copy or the override turns one of them RED."""
281
+ self.assertNotIn(WRITE_BODY_MARKER, self.captured["content"])
282
+ self.assertNotIn(BASH_COMMAND_MARKER, self.captured["content"])
283
+ # Positive shape check: the text-only formatter's role markers, not JSON.
284
+ self.assertIn("[role: assistant]", self.captured["content"])
285
+
286
+
287
+ class ProseFreeSidechainIsSkipped(unittest.TestCase):
288
+ """The degenerate case — a worker with NO assistant prose, only tool traffic.
289
+
290
+ Post-#3994 the volume gate counts ONLY the chars the text-only path keeps
291
+ (``retained_text_char_count`` -> ``_extract_text_content``), so a fork whose
292
+ bulk is entirely tool_use inputs + tool_result bodies now FAILS the char
293
+ floor: once tools are stripped there is almost nothing to retain, and the
294
+ tiny "go" instruction turns fall far under ``MIN_NON_TOOL_RESULT_CHARS``.
295
+ That is the correct outcome — the old gate cleared on tool-command volume and
296
+ then retained a near-empty document (the mismatch #3994 closes). This pins
297
+ the SKIP so a revert to counting tool_use chars goes RED here.
245
298
  """
246
299
 
247
300
  def _prose_free_sidechain(self, path: str, human_turns: int = 8) -> None:
@@ -249,8 +302,8 @@ class ProseFreeSidechainDegradesGracefully(unittest.TestCase):
249
302
  for i in range(human_turns):
250
303
  # tool_result-only user messages don't count as human turns, so the
251
304
  # instruction turns are plain strings — but they are the USER's
252
- # words. Keep them short so the retained text is dominated by
253
- # nothing at all once tools are stripped.
305
+ # words. Keep them short so, once tools are stripped, there is almost
306
+ # no retainable text left.
254
307
  lines.append(_entry("user", "go", f"u{i}"))
255
308
  lines.append(
256
309
  _entry(
@@ -269,29 +322,32 @@ class ProseFreeSidechainDegradesGracefully(unittest.TestCase):
269
322
  with open(path, "w", encoding="utf-8") as f:
270
323
  f.write("\n".join(lines) + "\n")
271
324
 
272
- def test_retains_the_human_turns_and_still_drops_the_tool_traffic(self):
325
+ def test_prose_free_fork_fails_the_recalibrated_char_floor(self):
273
326
  with tempfile.TemporaryDirectory() as d:
274
327
  sc = os.path.join(d, "agent-af5.jsonl")
275
328
  self._prose_free_sidechain(sc)
276
-
277
- # Precondition: the gate genuinely PASSES on tool_use chars alone,
278
- # so this is the "cleared the gate with no prose" case and not just
279
- # a trivial-fork skip.
280
329
  msgs = subagent_retain.read_transcript(sc)
330
+
331
+ # It clears MIN_HUMAN_TURNS (8 genuine "go" turns)...
332
+ self.assertGreaterEqual(
333
+ subagent_retain.count_human_turns(msgs), subagent_retain.MIN_HUMAN_TURNS
334
+ )
335
+ # ...but the recalibrated (text-only) char count is far below the
336
+ # floor, because every 4 KB Bash body is excluded from the count.
281
337
  passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
282
- self.assertTrue(passed, f"gate did not pass: turns={turns} chars={chars}")
283
-
284
- result, captured = _run_sidechain_retain(sc, d)
285
-
286
- # Retains, does not crash and does not enqueue a doomed pending retain.
287
- self.assertEqual(result["status"], "ok", result)
288
- body = captured["content"][len(subagent_retain.SIDECHAIN_MISSION_HEADER):].strip()
289
- # The human instruction turns survive nothing went silently empty.
290
- self.assertIn("[role: user]", body)
291
- self.assertIn("go", body)
292
- # The 4 KB Bash command bodies did NOT.
293
- self.assertNotIn("x" * 200, body)
294
- self.assertNotIn("true ", body)
338
+ self.assertFalse(
339
+ passed,
340
+ f"prose-free fork should now SKIP: turns={turns} chars={chars} "
341
+ f"(floor {subagent_retain.MIN_NON_TOOL_RESULT_CHARS})",
342
+ )
343
+ self.assertLess(chars, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
344
+
345
+ # End to end: the retain is skipped by the volume gate; nothing POSTs.
346
+ result, captured, _ = _run_sidechain_retain(sc, d)
347
+
348
+ self.assertEqual(result["status"], "skipped", result)
349
+ self.assertEqual(result["reason"], "volume gate")
350
+ self.assertEqual(captured, {}, "a skipped fork must not POST a retain")
295
351
 
296
352
 
297
353
  class TextOnlyPathIsNotEmpty(unittest.TestCase):