switchroom 0.19.33 → 0.19.35

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.
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env python3
2
+ """Sidechain retains carry LEARNINGS, not raw transcripts and tools.
3
+
4
+ Ken, 2026-07-29: "I don't want sub-agents' transcripts but definitely their
5
+ learnings should be captured but not raw transcripts and tools."
6
+
7
+ ``run_subagent_retain`` forces ``retainToolCalls = False`` on its config COPY,
8
+ which routes the window through ``_prepare_text_transcript`` /
9
+ ``_extract_text_content`` instead of ``_prepare_json_transcript`` /
10
+ ``_extract_message_blocks``. These tests pin the observable consequences:
11
+
12
+ 1. Structural exclusion — a ``Write`` body, a ``Bash`` command and a
13
+ ``tool_result`` marker are all absent from the POSTed content, while an
14
+ assistant prose finding survives; and the content is NOT JSON (proving the
15
+ text path, not the JSON path, produced it).
16
+ 2. Nothing goes silently empty — the text-only formatter still yields a
17
+ substantive transcript for a representative sidechain fixture.
18
+
19
+ Stdlib-only, hermetic (no network, no live container); runs under
20
+ ``python3 -m unittest discover tests/``.
21
+ """
22
+
23
+ import contextlib
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import unittest
29
+ from unittest import mock
30
+
31
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
32
+ if SCRIPTS_DIR not in sys.path:
33
+ sys.path.insert(0, SCRIPTS_DIR)
34
+
35
+ import subagent_retain # noqa: E402
36
+ from lib.content import prepare_retention_transcript # noqa: E402
37
+
38
+ # Mirrors tests/test_subagent_retain.py CONFIG, including its
39
+ # ``retainToolCalls: True`` — the point is that the sidechain path must override
40
+ # it regardless of what the operator configured.
41
+ CONFIG = {
42
+ "autoRetain": True,
43
+ "retainMode": "chunked",
44
+ "retainRoles": ["user", "assistant"],
45
+ "retainToolCalls": True,
46
+ "retainTags": ["{session_id}"],
47
+ "retainMetadata": {},
48
+ "retainContext": "claude-code",
49
+ "hindsightApiToken": None,
50
+ "debug": False,
51
+ }
52
+
53
+ # Distinctive markers. Each must be absent from the retained content.
54
+ WRITE_BODY_MARKER = "MARKER_WRITE_FILE_BODY_9f3a"
55
+ BASH_COMMAND_MARKER = "MARKER_BASH_COMMAND_c71d"
56
+ TOOL_RESULT_MARKER = "MARKER_TOOL_RESULT_PAYLOAD_4e82"
57
+
58
+ # The one thing that MUST survive: the sub-agent's own prose finding.
59
+ FINDING = (
60
+ "Durable finding: the recall hook's stderr warning is swallowed by the CLI, "
61
+ "so directives_omitted on the recall_log row is the only visible signal."
62
+ )
63
+
64
+
65
+ def _entry(role: str, content, uuid: str) -> str:
66
+ """One nested Claude Code sidechain jsonl entry."""
67
+ return json.dumps(
68
+ {
69
+ "type": role,
70
+ "isSidechain": True,
71
+ "agentId": "af5fba739c0ee6b38",
72
+ "sessionId": "parentsess",
73
+ "uuid": uuid,
74
+ "message": {"role": role, "content": content},
75
+ }
76
+ )
77
+
78
+
79
+ def _tool_heavy_sidechain(path: str, *, human_turns: int = 8) -> None:
80
+ """A sidechain transcript that is mostly tool traffic plus one prose finding.
81
+
82
+ 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.
85
+ """
86
+ lines = []
87
+ for i in range(human_turns):
88
+ lines.append(_entry("user", f"instruction {i}: keep going with the audit task", f"u{i}"))
89
+ lines.append(
90
+ _entry(
91
+ "assistant",
92
+ [
93
+ {
94
+ "type": "thinking",
95
+ "thinking": "internal reasoning that must never be retained",
96
+ },
97
+ {
98
+ "type": "tool_use",
99
+ "id": f"tu_w{i}",
100
+ "name": "Write",
101
+ # ~50 KB body — the class of payload this change exists to drop.
102
+ "input": {
103
+ "file_path": f"/tmp/scratch/out{i}.txt",
104
+ "content": WRITE_BODY_MARKER + ("Z" * 50_000),
105
+ },
106
+ },
107
+ {
108
+ "type": "tool_use",
109
+ "id": f"tu_b{i}",
110
+ "name": "Bash",
111
+ "input": {"command": f"{BASH_COMMAND_MARKER} --iteration {i}"},
112
+ },
113
+ ],
114
+ f"a{i}",
115
+ )
116
+ )
117
+ lines.append(
118
+ _entry(
119
+ "user",
120
+ [
121
+ {
122
+ "type": "tool_result",
123
+ "tool_use_id": f"tu_b{i}",
124
+ "content": TOOL_RESULT_MARKER + " " + ("q" * 4000),
125
+ }
126
+ ],
127
+ f"tr{i}",
128
+ )
129
+ )
130
+
131
+ # The sub-agent's own prose, last — the learning that must survive.
132
+ lines.append(_entry("user", "wrap up and report what you learned", "ufinal"))
133
+ lines.append(_entry("assistant", [{"type": "text", "text": FINDING}], "afinal"))
134
+
135
+ with open(path, "w", encoding="utf-8") as f:
136
+ f.write("\n".join(lines) + "\n")
137
+
138
+
139
+ @contextlib.contextmanager
140
+ def _lock_acquired(blocking=False):
141
+ yield True
142
+
143
+
144
+ def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
145
+ """Drive run_subagent_retain with a stub client, returning (result, kwargs)."""
146
+ captured = {}
147
+
148
+ class _Client:
149
+ def __init__(self, *a, **k):
150
+ pass
151
+
152
+ def retain(self, **kwargs):
153
+ captured.update(kwargs)
154
+ return {"ok": True}
155
+
156
+ config = dict(CONFIG, **(config_extra or {}))
157
+ hook_input = {
158
+ "session_id": "parentsess",
159
+ "agent_id": "af5",
160
+ "agent_type": "worker",
161
+ "agent_transcript_path": sidechain_path,
162
+ "transcript_path": os.path.join(tmpdir, "parentsess.jsonl"),
163
+ "cwd": tmpdir,
164
+ }
165
+ with mock.patch("subagent_retain.load_config", return_value=config), \
166
+ mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
167
+ mock.patch("subagent_retain.ensure_bank_mission"), \
168
+ mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
169
+ mock.patch("subagent_retain.HindsightClient", _Client), \
170
+ mock.patch("subagent_retain.inflight_lock", _lock_acquired):
171
+ result = subagent_retain.run_subagent_retain(hook_input)
172
+ return result, captured
173
+
174
+
175
+ class SidechainRetainsLearningsNotTools(unittest.TestCase):
176
+ """The retained content is the sub-agent's prose, not its tool traffic."""
177
+
178
+ def setUp(self):
179
+ self._tmp = tempfile.TemporaryDirectory()
180
+ self.addCleanup(self._tmp.cleanup)
181
+ self.sidechain = os.path.join(self._tmp.name, "agent-af5.jsonl")
182
+ _tool_heavy_sidechain(self.sidechain)
183
+ self.result, self.captured = _run_sidechain_retain(self.sidechain, self._tmp.name)
184
+ self.assertEqual(self.result["status"], "ok", self.result)
185
+ self.content = self.captured["content"]
186
+
187
+ def test_sub_agent_prose_finding_survives(self):
188
+ self.assertIn(FINDING, self.content)
189
+
190
+ def test_write_tool_body_is_excluded(self):
191
+ self.assertNotIn(WRITE_BODY_MARKER, self.content)
192
+ # And the 50 KB body itself is nowhere in the payload.
193
+ self.assertNotIn("Z" * 200, self.content)
194
+
195
+ def test_bash_command_is_excluded(self):
196
+ self.assertNotIn(BASH_COMMAND_MARKER, self.content)
197
+
198
+ def test_tool_result_body_is_excluded(self):
199
+ self.assertNotIn(TOOL_RESULT_MARKER, self.content)
200
+
201
+ def test_thinking_blocks_are_excluded(self):
202
+ self.assertNotIn("internal reasoning that must never be retained", self.content)
203
+
204
+ def test_content_is_not_the_json_transcript_form(self):
205
+ """Proves we are on the text path, not _prepare_json_transcript.
206
+
207
+ The JSON path emits a ``json.dumps`` list of ``{role, content: [blocks]}``;
208
+ the text path emits ``[role: x]...[x:end]`` markers, which is not JSON.
209
+ The mission header is prepended to both, so strip it first — otherwise
210
+ this would pass for the wrong reason.
211
+ """
212
+ body = self.content[len(subagent_retain.SIDECHAIN_MISSION_HEADER):].strip()
213
+ self.assertTrue(body, "no transcript body after the mission header")
214
+ with self.assertRaises(json.JSONDecodeError):
215
+ json.loads(body)
216
+ # Positive check on the text-path shape.
217
+ self.assertIn("[role: assistant]", body)
218
+
219
+ def test_payload_is_far_smaller_than_the_raw_transcript(self):
220
+ """Volume, not just shape: the whole point is fewer chars to extract."""
221
+ raw_bytes = os.path.getsize(self.sidechain)
222
+ self.assertLess(
223
+ len(self.content),
224
+ raw_bytes / 5,
225
+ f"retained {len(self.content)} chars from a {raw_bytes}-byte transcript "
226
+ "— expected a large reduction on the text-only path",
227
+ )
228
+
229
+ 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.
236
+
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.
245
+ """
246
+
247
+ def _prose_free_sidechain(self, path: str, human_turns: int = 8) -> None:
248
+ lines = []
249
+ for i in range(human_turns):
250
+ # tool_result-only user messages don't count as human turns, so the
251
+ # 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.
254
+ lines.append(_entry("user", "go", f"u{i}"))
255
+ lines.append(
256
+ _entry(
257
+ "assistant",
258
+ [
259
+ {
260
+ "type": "tool_use",
261
+ "id": f"tu{i}",
262
+ "name": "Bash",
263
+ "input": {"command": "true " + ("x" * 4000)},
264
+ }
265
+ ],
266
+ f"a{i}",
267
+ )
268
+ )
269
+ with open(path, "w", encoding="utf-8") as f:
270
+ f.write("\n".join(lines) + "\n")
271
+
272
+ def test_retains_the_human_turns_and_still_drops_the_tool_traffic(self):
273
+ with tempfile.TemporaryDirectory() as d:
274
+ sc = os.path.join(d, "agent-af5.jsonl")
275
+ 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
+ msgs = subagent_retain.read_transcript(sc)
281
+ 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)
295
+
296
+
297
+ class TextOnlyPathIsNotEmpty(unittest.TestCase):
298
+ """A representative sidechain still yields a substantive transcript.
299
+
300
+ Prior measurement over 200 real klanker sidechain transcripts found 0 empty
301
+ and 0 under 500 chars (p10 5,035 / p50 10,058 / p90 26,040 chars). A floor
302
+ assertion, deliberately not an exact size.
303
+ """
304
+
305
+ # Well under the measured p10 (5,035) — this guards "silently empty",
306
+ # not the exact reduction ratio.
307
+ MIN_TRANSCRIPT_CHARS = 500
308
+
309
+ def _representative_messages(self):
310
+ """Prose-bearing assistant turns interleaved with tool traffic."""
311
+ msgs = []
312
+ for i in range(10):
313
+ msgs.append({"role": "user", "content": f"step {i}: continue the investigation"})
314
+ msgs.append(
315
+ {
316
+ "role": "assistant",
317
+ "content": [
318
+ {"type": "thinking", "thinking": "internal " * 50},
319
+ {
320
+ "type": "text",
321
+ "text": (
322
+ f"Step {i}: confirmed the guard only probes observation rows, "
323
+ "so world/experience facts never traverse the semantic dedup "
324
+ "path. That is why overlap persists as extra rows."
325
+ ),
326
+ },
327
+ {
328
+ "type": "tool_use",
329
+ "id": f"tu{i}",
330
+ "name": "Bash",
331
+ "input": {"command": "grep -rn dedup " + ("x" * 3000)},
332
+ },
333
+ ],
334
+ }
335
+ )
336
+ msgs.append(
337
+ {
338
+ "role": "user",
339
+ "content": [
340
+ {"type": "tool_result", "tool_use_id": f"tu{i}", "content": "y" * 5000}
341
+ ],
342
+ }
343
+ )
344
+ return msgs
345
+
346
+ def test_text_only_transcript_is_non_empty_and_non_trivial(self):
347
+ transcript, count = prepare_retention_transcript(
348
+ self._representative_messages(),
349
+ ["user", "assistant"],
350
+ True,
351
+ include_tool_calls=False,
352
+ )
353
+ self.assertIsNotNone(transcript, "text-only path formatted to nothing")
354
+ self.assertGreaterEqual(
355
+ len(transcript),
356
+ self.MIN_TRANSCRIPT_CHARS,
357
+ f"text-only transcript is only {len(transcript)} chars — below the "
358
+ f"{self.MIN_TRANSCRIPT_CHARS}-char floor",
359
+ )
360
+ self.assertGreater(count, 0)
361
+ # The prose is what carries; the tool traffic is what went.
362
+ self.assertIn("never traverse the semantic dedup", transcript)
363
+ self.assertNotIn("grep -rn dedup", transcript)
364
+
365
+ def test_text_only_is_materially_smaller_than_json_form(self):
366
+ msgs = self._representative_messages()
367
+ text_form, _ = prepare_retention_transcript(
368
+ msgs, ["user", "assistant"], True, include_tool_calls=False
369
+ )
370
+ json_form, _ = prepare_retention_transcript(
371
+ msgs, ["user", "assistant"], True, include_tool_calls=True
372
+ )
373
+ self.assertLess(len(text_form), len(json_form) / 5)
374
+
375
+
376
+ if __name__ == "__main__":
377
+ unittest.main()