switchroom 0.17.0 → 0.17.2

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.
@@ -167,13 +167,48 @@ def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
167
167
  # ---------------------------------------------------------------------------
168
168
 
169
169
 
170
+ def _is_tool_result_only_user_message(message: dict) -> bool:
171
+ """True when a ``role="user"`` message carries ONLY tool_result blocks.
172
+
173
+ SWITCHROOM DIVERGENCE (candidate to upstream to vectorize-io/hindsight):
174
+ Claude Code emits tool results as ``role="user"`` messages whose content
175
+ is a list of ``{"type": "tool_result", ...}`` blocks — they are NOT
176
+ human turns. A genuine human turn has text (a string, or a content list
177
+ with at least one non-tool_result block, e.g. ``{"type": "text"}`` or an
178
+ image). Treating tool_result messages as turn boundaries lets a tool-heavy
179
+ turn (≥N sequential tool rounds) fill a fixed-size retain window with
180
+ tool_result messages and push the actual human message OUTSIDE the window
181
+ — silently dropping the fact from that fire, and from every later fire
182
+ (whose window starts even further from the human message). On restart the
183
+ fact is gone. This helper lets the boundary counter skip those messages so
184
+ "window = N turns" means N *human* turns regardless of tool volume.
185
+ """
186
+ if message.get("role") != "user":
187
+ return False
188
+ content = message.get("content")
189
+ if isinstance(content, list):
190
+ blocks = [b for b in content if isinstance(b, dict)]
191
+ # A non-empty content list that is ENTIRELY tool_result blocks.
192
+ if blocks and all(b.get("type") == "tool_result" for b in blocks):
193
+ return True
194
+ return False
195
+
196
+
170
197
  def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
171
198
  """Slice messages to the last N turns, where a turn starts at a user message.
172
199
 
173
200
  Port of: sliceLastTurnsByUserBoundary() in index.js
174
201
 
175
- Walks backward counting user messages as turn boundaries. Returns
176
- messages from the Nth user boundary to the end.
202
+ Walks backward counting GENUINE HUMAN user messages as turn boundaries.
203
+ Returns messages from the Nth human boundary to the end.
204
+
205
+ SWITCHROOM DIVERGENCE (candidate to upstream): tool_result messages carry
206
+ ``role="user"`` in the Claude Code transcript but are not human turns; they
207
+ are skipped as boundaries (see ``_is_tool_result_only_user_message``). This
208
+ keeps the fixed-size retain window anchored to human turns so a tool-heavy
209
+ turn can never push the human's fact outside the window (silent memory loss).
210
+ Affects both the retain window-slice and the recall context slice — both
211
+ want "N human turns", not "N transcript user-messages".
177
212
  """
178
213
  if not isinstance(messages, list) or not messages or turns <= 0:
179
214
  return []
@@ -182,7 +217,8 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
182
217
  start_index = -1
183
218
 
184
219
  for i in range(len(messages) - 1, -1, -1):
185
- if messages[i].get("role") == "user":
220
+ msg = messages[i]
221
+ if msg.get("role") == "user" and not _is_tool_result_only_user_message(msg):
186
222
  user_turns_seen += 1
187
223
  if user_turns_seen >= turns:
188
224
  start_index = i
@@ -69,6 +69,60 @@ def read_transcript(transcript_path: str) -> list:
69
69
  return messages
70
70
 
71
71
 
72
+ def select_retain_window(
73
+ retain_mode: str,
74
+ retain_every_n: int,
75
+ overlap_turns: int,
76
+ all_messages: list,
77
+ force: bool = False,
78
+ ) -> tuple:
79
+ """Decide which messages to retain and whether to send as a full window.
80
+
81
+ Returns ``(messages_to_retain, retain_full_window)``.
82
+
83
+ SWITCHROOM DIVERGENCE (Phase 6b — candidate to upstream to
84
+ vectorize-io/hindsight): the chunked sliding-window is decoupled from
85
+ the ``retainEveryNTurns > 1`` throttle. Upstream only sliced a window
86
+ when ``retain_every_n > 1``; with ``retainEveryNTurns=1`` (switchroom's
87
+ every-turn crash-durability setting, applied in scaffold.ts) chunked
88
+ mode fell through to full-session and re-consolidated the ENTIRE
89
+ accumulated transcript on every Stop fire — an unbounded, per-turn cost.
90
+
91
+ Decoupling is safe because window selection and the throttle answer two
92
+ independent questions: the throttle decides *whether* to fire this turn
93
+ (still owned by run_retain, unchanged); this function only decides *what*
94
+ to retain once a fire happens. A chunked window of
95
+ ``max(retain_every_n, 1) + overlap_turns`` turns is correct for any
96
+ ``retain_every_n >= 1``. With ``retain_every_n=1, overlap=2`` the window
97
+ is the 3 most-recent HUMAN turns (tool_result messages don't count as
98
+ turns — see slice_last_turns_by_user_boundary).
99
+
100
+ ``force=True`` (SessionEnd final retain) widens chunked mode to a
101
+ full-session sweep — belt-and-braces so a graceful shutdown always flushes
102
+ the whole session even if per-turn windowing had an edge. This costs a
103
+ full sweep only ONCE per session (at end), not per turn.
104
+
105
+ Durability invariant (jtbd-memory-survives-restart UAT): the window
106
+ always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
107
+ returns ``messages[start:]``), so the turn that just completed — the one
108
+ whose Stop hook is firing — is ALWAYS included. Every turn fires (no
109
+ throttle at n=1), so every turn's content is retained on its own fire.
110
+ Boundaries are counted on human messages only, so a tool-heavy turn can't
111
+ push the human's fact outside the window. No fact can fall outside every
112
+ window.
113
+ """
114
+ if retain_mode == "chunked" and not force:
115
+ # Sliding window: N turns + configured overlap. max(retain_every_n, 1)
116
+ # keeps the window valid at n=1 (the decoupling); for n>1 this equals
117
+ # the previous `retain_every_n + overlap_turns` (behaviour unchanged).
118
+ window_turns = max(retain_every_n, 1) + overlap_turns
119
+ messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
120
+ return messages_to_retain, True
121
+ # Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
122
+ # sweep. Retain all messages, always as a full window.
123
+ return list(all_messages), True
124
+
125
+
72
126
  def run_retain(hook_input: dict, force: bool = False) -> dict:
73
127
  """Run the auto-retain flow.
74
128
 
@@ -104,7 +158,8 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
104
158
 
105
159
  debug_log(config, f"Read {len(all_messages)} messages from transcript")
106
160
 
107
- # Retention mode: full session (default) or chunked (legacy)
161
+ # Retention mode: full session (vendor default) or chunked. Switchroom
162
+ # runs chunked at retainEveryNTurns=1 (see select_retain_window / scaffold.ts).
108
163
  retain_mode = config.get("retainMode", "full-session")
109
164
  retain_every_n = max(1, config.get("retainEveryNTurns", 1))
110
165
  retain_full_window = False
@@ -118,19 +173,25 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
118
173
  debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
119
174
  return {"status": "skipped", "reason": "throttled"}
120
175
 
121
- if retain_mode == "chunked" and retain_every_n > 1:
122
- # Sliding window: N turns + configured overlap
123
- overlap_turns = config.get("retainOverlapTurns", 0)
124
- window_turns = retain_every_n + overlap_turns
125
- messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
126
- retain_full_window = True
176
+ # Window selection is decoupled from the throttle above — see
177
+ # select_retain_window() for the switchroom-divergence rationale
178
+ # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
179
+ overlap_turns = config.get("retainOverlapTurns", 0)
180
+ messages_to_retain, retain_full_window = select_retain_window(
181
+ retain_mode, retain_every_n, overlap_turns, all_messages, force=force
182
+ )
183
+ if retain_mode == "chunked" and not force:
184
+ window_turns = max(retain_every_n, 1) + overlap_turns
185
+ debug_log(
186
+ config,
187
+ f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
188
+ )
189
+ elif retain_mode == "chunked" and force:
127
190
  debug_log(
128
191
  config,
129
- f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)",
192
+ f"Chunked retain, forced full-session sweep (SessionEnd): {len(all_messages)} messages",
130
193
  )
131
194
  else:
132
- # Full session mode: retain all messages, always as full window
133
- retain_full_window = True
134
195
  debug_log(config, f"Full session retain: {len(all_messages)} messages")
135
196
 
136
197
  # Format transcript
@@ -0,0 +1,126 @@
1
+ """Switchroom follow-up to #2830 — recall-side coverage of the tool-boundary
2
+ slice fix.
3
+
4
+ #2830 fixed silent-memory-loss in ``slice_last_turns_by_user_boundary``:
5
+ Claude Code emits tool results as ``role="user"`` messages, and the old
6
+ boundary counter treated each one as a turn. A tool-heavy turn (>=N sequential
7
+ tool rounds) could therefore fill a fixed-size window with tool_result
8
+ pseudo-turns and push the real human message OUTSIDE it.
9
+
10
+ That same slice function backs TWO call sites:
11
+ 1. the RETAIN window (covered by ``test_retain_window.py``), and
12
+ 2. the RECALL context slice, via ``compose_recall_query``.
13
+
14
+ #2830 shipped a test only for the retain path. This file closes the reviewer
15
+ nit by exercising the RECALL path end-to-end over a tool-heavy transcript:
16
+ the composed recall query must still carry the real human turn's text and must
17
+ not be truncated at the tool_result pseudo-boundaries.
18
+
19
+ These tests FAIL if the ``_is_tool_result_only_user_message`` guard is reverted
20
+ (the tool_result messages become boundaries again and the human turn is sliced
21
+ off), and pass with the guard in place.
22
+
23
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
24
+ """
25
+
26
+ import os
27
+ import sys
28
+ import unittest
29
+
30
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
31
+ if SCRIPTS_DIR not in sys.path:
32
+ sys.path.insert(0, SCRIPTS_DIR)
33
+
34
+ from lib.content import compose_recall_query # noqa: E402
35
+
36
+
37
+ def _human_msg(text: str) -> dict:
38
+ return {"role": "user", "content": text}
39
+
40
+
41
+ def _assistant_msg(text: str) -> dict:
42
+ return {"role": "assistant", "content": text}
43
+
44
+
45
+ def _tool_result_msg(tool_use_id: str, text: str) -> dict:
46
+ # Claude Code emits tool results as role="user" with a content list of
47
+ # tool_result blocks — exactly the shape read_transcript() produces.
48
+ return {
49
+ "role": "user",
50
+ "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": text}],
51
+ }
52
+
53
+
54
+ class ComposeRecallQueryToolHeavyTurn(unittest.TestCase):
55
+ """The recall context slice must count HUMAN turns, not tool_result
56
+ pseudo-turns — otherwise a tool-heavy turn drops the human's text from the
57
+ recall query, so recall searches on the tool output instead of what the
58
+ human actually said.
59
+ """
60
+
61
+ def test_tool_heavy_prior_turn_keeps_human_text_in_recall_context(self):
62
+ # A prior human turn stating a fact, then 3 sequential tool rounds,
63
+ # then the assistant answer. The current (latest) query is separate.
64
+ # recall_context_turns=2 asks for the latest turn + one prior HUMAN
65
+ # turn. OLD boundary semantics count the 3 tool_result "user" messages
66
+ # as turns and never reach the human fact; NEW semantics skip them and
67
+ # anchor to the real human turn, so its text lands in "Prior context:".
68
+ prior_fact = "my prod database is called ORCHID_PRIMARY"
69
+ messages = [
70
+ _human_msg(prior_fact),
71
+ _assistant_msg("let me look that up"),
72
+ _tool_result_msg("t1", "queried schema table 1"),
73
+ _assistant_msg("checking more"),
74
+ _tool_result_msg("t2", "queried schema table 2"),
75
+ _assistant_msg("one more"),
76
+ _tool_result_msg("t3", "queried schema table 3"),
77
+ _assistant_msg("here is your schema"),
78
+ ]
79
+ result = compose_recall_query(
80
+ "what port does it listen on",
81
+ messages,
82
+ recall_context_turns=2,
83
+ )
84
+ self.assertIn("Prior context:", result)
85
+ self.assertIn(
86
+ "ORCHID_PRIMARY",
87
+ result,
88
+ "human turn text was sliced out of the recall context by the "
89
+ "tool_result pseudo-boundaries (recall-side silent memory loss). "
90
+ "Composed query was: " + repr(result),
91
+ )
92
+ # The tool_result content must NOT leak in as if it were a human turn.
93
+ self.assertNotIn("queried schema table", result)
94
+
95
+ def test_recall_context_anchors_to_human_turns_across_tool_volume(self):
96
+ # Two prior human turns, the older one carrying a fact, each turn
97
+ # followed by tool rounds. recall_context_turns=3 (latest + 2 prior
98
+ # HUMAN turns) must reach back past ALL the tool_result messages to the
99
+ # oldest human turn — tool volume must not consume the turn budget.
100
+ oldest_fact = "the deploy key is FALCON_9_KEY"
101
+ messages = [
102
+ _human_msg(oldest_fact),
103
+ _assistant_msg("looking"),
104
+ _tool_result_msg("t1", "tool output alpha"),
105
+ _assistant_msg("more"),
106
+ _tool_result_msg("t2", "tool output beta"),
107
+ _human_msg("and remind me of the region too"),
108
+ _assistant_msg("checking region"),
109
+ _tool_result_msg("t3", "tool output gamma"),
110
+ _assistant_msg("region is ap-southeast-2"),
111
+ ]
112
+ result = compose_recall_query(
113
+ "put those together for me",
114
+ messages,
115
+ recall_context_turns=3,
116
+ )
117
+ self.assertIn("Prior context:", result)
118
+ # Both prior HUMAN turns survive; the oldest human fact is reached.
119
+ self.assertIn("FALCON_9_KEY", result)
120
+ self.assertIn("and remind me of the region too", result)
121
+ # No tool_result payload masquerades as human context.
122
+ self.assertNotIn("tool output", result)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -0,0 +1,261 @@
1
+ """Switchroom Phase 6b — unit tests for retain.py's window selection.
2
+
3
+ `select_retain_window()` decides WHAT to retain once a Stop-hook fire
4
+ happens (the throttle, which decides WHETHER to fire, is separate and
5
+ untouched here). Phase 6b decouples the chunked sliding-window from the
6
+ `retainEveryNTurns > 1` gate so chunked mode works at
7
+ `retainEveryNTurns=1` — switchroom's every-turn crash-durability setting.
8
+
9
+ The load-bearing property is DURABILITY: with every-turn firing, the
10
+ window must always include the turn that just completed, so a fact told
11
+ in a ≤2-turn session survives a restart (the jtbd-memory-survives-restart
12
+ UAT). Because the window always extends to the END of the transcript, the
13
+ just-completed turn is always inside it.
14
+
15
+ Stdlib-only; runs under `python3 -m unittest discover tests/`.
16
+ """
17
+
18
+ import os
19
+ import sys
20
+ import unittest
21
+
22
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
23
+ if SCRIPTS_DIR not in sys.path:
24
+ sys.path.insert(0, SCRIPTS_DIR)
25
+
26
+ from retain import select_retain_window # noqa: E402
27
+
28
+
29
+ def _transcript(num_turns: int) -> list:
30
+ """Build a transcript of `num_turns` user/assistant turns.
31
+
32
+ Each turn = one user message followed by one assistant message. The
33
+ text encodes the turn index so slices can be identified precisely.
34
+ """
35
+ messages = []
36
+ for i in range(num_turns):
37
+ messages.append({"role": "user", "content": f"user turn {i}"})
38
+ messages.append({"role": "assistant", "content": f"assistant turn {i}"})
39
+ return messages
40
+
41
+
42
+ def _user_turn_indices(messages: list) -> list:
43
+ """Return the turn indices present in a sliced message list."""
44
+ return [
45
+ int(m["content"].split()[-1])
46
+ for m in messages
47
+ if m.get("role") == "user"
48
+ ]
49
+
50
+
51
+ class SelectRetainWindowChunkedEveryTurn(unittest.TestCase):
52
+ """The Phase 6b behaviour: chunked slicing works at retain_every_n=1."""
53
+
54
+ def test_chunked_n1_slices_recent_window_not_full_session(self):
55
+ # n=1, overlap=2 -> window = max(1,1)+2 = 3 recent turns.
56
+ messages = _transcript(5) # turns 0..4
57
+ result, full_window = select_retain_window(
58
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
59
+ )
60
+ # Only the last 3 turns, NOT all 5 — this is the cost fix.
61
+ self.assertEqual(_user_turn_indices(result), [2, 3, 4])
62
+ self.assertLess(len(result), len(messages))
63
+ self.assertTrue(full_window)
64
+
65
+ def test_chunked_n1_window_always_includes_the_just_completed_turn(self):
66
+ # DURABILITY: the newest turn (highest index) must be in the window.
67
+ for total in (1, 2, 3, 4, 10, 50):
68
+ messages = _transcript(total)
69
+ result, _ = select_retain_window(
70
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
71
+ )
72
+ newest = total - 1
73
+ self.assertIn(
74
+ newest,
75
+ _user_turn_indices(result),
76
+ f"newest turn {newest} missing from window (total={total})",
77
+ )
78
+ # Window always extends to the very end of the transcript.
79
+ self.assertEqual(result[-1], messages[-1])
80
+
81
+ def test_single_turn_session_retains_that_turn(self):
82
+ # The restart-survival case: a 1-turn session. Window (3) exceeds
83
+ # available turns, so the whole (1-turn) transcript is retained.
84
+ messages = _transcript(1)
85
+ result, full_window = select_retain_window(
86
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
87
+ )
88
+ self.assertEqual(_user_turn_indices(result), [0])
89
+ self.assertEqual(result, messages)
90
+ self.assertTrue(full_window)
91
+
92
+ def test_two_turn_session_retains_both_turns(self):
93
+ # The exact jtbd shape: fact told in turn 0, one more turn, restart.
94
+ messages = _transcript(2)
95
+ result, _ = select_retain_window(
96
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
97
+ )
98
+ self.assertEqual(_user_turn_indices(result), [0, 1])
99
+
100
+
101
+ class SelectRetainWindowNoRegression(unittest.TestCase):
102
+ """The n>1 chunked path and the full-session path must be unchanged."""
103
+
104
+ def test_chunked_n_gt_1_window_is_n_plus_overlap(self):
105
+ # n=10, overlap=2 -> window = 12 turns, identical to the pre-Phase-6b
106
+ # `retain_every_n + overlap_turns` formula (max(10,1)==10).
107
+ messages = _transcript(20) # turns 0..19
108
+ result, full_window = select_retain_window(
109
+ "chunked", retain_every_n=10, overlap_turns=2, all_messages=messages
110
+ )
111
+ self.assertEqual(_user_turn_indices(result), list(range(8, 20))) # last 12
112
+ self.assertTrue(full_window)
113
+
114
+ def test_chunked_zero_overlap(self):
115
+ # n=1, overlap=0 -> window = 1 (just the current turn).
116
+ messages = _transcript(5)
117
+ result, _ = select_retain_window(
118
+ "chunked", retain_every_n=1, overlap_turns=0, all_messages=messages
119
+ )
120
+ self.assertEqual(_user_turn_indices(result), [4])
121
+
122
+ def test_full_session_retains_all_regardless_of_n(self):
123
+ messages = _transcript(7)
124
+ for n in (1, 5, 10):
125
+ result, full_window = select_retain_window(
126
+ "full-session", retain_every_n=n, overlap_turns=2, all_messages=messages
127
+ )
128
+ self.assertEqual(_user_turn_indices(result), list(range(7)))
129
+ self.assertEqual(len(result), len(messages))
130
+ self.assertTrue(full_window)
131
+
132
+ def test_unknown_mode_falls_back_to_full_session(self):
133
+ messages = _transcript(4)
134
+ result, full_window = select_retain_window(
135
+ "something-else", retain_every_n=1, overlap_turns=2, all_messages=messages
136
+ )
137
+ self.assertEqual(len(result), len(messages))
138
+ self.assertTrue(full_window)
139
+
140
+ def test_returns_a_copy_not_the_same_list_for_full_session(self):
141
+ # select_retain_window returns list(all_messages) for full-session,
142
+ # so mutating the result can't corrupt the caller's transcript.
143
+ messages = _transcript(2)
144
+ result, _ = select_retain_window(
145
+ "full-session", retain_every_n=1, overlap_turns=2, all_messages=messages
146
+ )
147
+ self.assertIsNot(result, messages)
148
+
149
+
150
+ def _human_msg(text: str) -> dict:
151
+ return {"role": "user", "content": text}
152
+
153
+
154
+ def _tool_result_msg(tool_use_id: str, text: str) -> dict:
155
+ # Claude Code emits tool results as role="user" with a content list of
156
+ # tool_result blocks — exactly the shape read_transcript() produces.
157
+ return {
158
+ "role": "user",
159
+ "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": text}],
160
+ }
161
+
162
+
163
+ def _assistant_msg(text: str) -> dict:
164
+ return {"role": "assistant", "content": text}
165
+
166
+
167
+ def _contains_text(messages: list, needle: str) -> bool:
168
+ for m in messages:
169
+ c = m.get("content")
170
+ if isinstance(c, str) and needle in c:
171
+ return True
172
+ return False
173
+
174
+
175
+ class SelectRetainWindowToolHeavyTurn(unittest.TestCase):
176
+ """Finding 1 regression: tool_result messages are role="user" but are NOT
177
+ human turns. A tool-heavy turn must not push the human's fact out of the
178
+ window. These FAIL before the content.py boundary fix and pass after.
179
+ """
180
+
181
+ def test_tool_heavy_single_turn_keeps_human_fact_in_window(self):
182
+ # One logical human turn: the fact, then 3 sequential tool rounds.
183
+ # OLD boundary semantics count the 3 tool_result "user" messages as
184
+ # 3 turns and slice them off — dropping the human fact. NEW semantics
185
+ # count only the 1 human turn, so the whole (1 human-turn) transcript
186
+ # is retained and the fact survives.
187
+ fact = "my deploy token is DURABILITY_TOKEN_XYZ"
188
+ messages = [
189
+ _human_msg(fact),
190
+ _assistant_msg("let me look that up"),
191
+ _tool_result_msg("t1", "file a"),
192
+ _assistant_msg("checking more"),
193
+ _tool_result_msg("t2", "file b"),
194
+ _assistant_msg("one more"),
195
+ _tool_result_msg("t3", "file c"),
196
+ _assistant_msg("here is your answer"),
197
+ ]
198
+ result, _ = select_retain_window(
199
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
200
+ )
201
+ self.assertTrue(
202
+ _contains_text(result, "DURABILITY_TOKEN_XYZ"),
203
+ "human fact fell outside the retain window on a tool-heavy turn "
204
+ "(silent memory loss). Window was: "
205
+ + repr([m.get("content") for m in result]),
206
+ )
207
+
208
+ def test_tool_heavy_current_turn_among_prior_human_turns(self):
209
+ # Two prior human turns, then a tool-heavy current turn whose human
210
+ # message carries the fact. window=3 human turns must include the
211
+ # current turn's human message regardless of tool volume.
212
+ fact = "the current fact is CURRENT_FACT_42"
213
+ messages = [
214
+ _human_msg("older turn 0"),
215
+ _assistant_msg("a0"),
216
+ _human_msg("older turn 1"),
217
+ _assistant_msg("a1"),
218
+ _human_msg(fact),
219
+ _assistant_msg("looking"),
220
+ _tool_result_msg("t1", "r1"),
221
+ _assistant_msg("more"),
222
+ _tool_result_msg("t2", "r2"),
223
+ _assistant_msg("more"),
224
+ _tool_result_msg("t3", "r3"),
225
+ _assistant_msg("done"),
226
+ ]
227
+ result, _ = select_retain_window(
228
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
229
+ )
230
+ self.assertTrue(_contains_text(result, "CURRENT_FACT_42"))
231
+ # And the window is anchored to 3 HUMAN turns — so the oldest human
232
+ # message ("older turn 0") is the window start.
233
+ self.assertTrue(_contains_text(result, "older turn 0"))
234
+
235
+
236
+ class SelectRetainWindowForce(unittest.TestCase):
237
+ """Finding 2: SessionEnd force=True widens chunked mode to full-session."""
238
+
239
+ def test_force_retains_full_session_in_chunked_mode(self):
240
+ messages = _transcript(10) # turns 0..9
241
+ result, full_window = select_retain_window(
242
+ "chunked", retain_every_n=1, overlap_turns=2,
243
+ all_messages=messages, force=True,
244
+ )
245
+ # Forced sweep at SessionEnd retains everything, not just the window.
246
+ self.assertEqual(_user_turn_indices(result), list(range(10)))
247
+ self.assertEqual(len(result), len(messages))
248
+ self.assertTrue(full_window)
249
+
250
+ def test_no_force_still_windows_in_chunked_mode(self):
251
+ # Guard: the force widening must not leak into normal per-turn fires.
252
+ messages = _transcript(10)
253
+ result, _ = select_retain_window(
254
+ "chunked", retain_every_n=1, overlap_turns=2,
255
+ all_messages=messages, force=False,
256
+ )
257
+ self.assertEqual(_user_turn_indices(result), [7, 8, 9]) # last 3 turns
258
+
259
+
260
+ if __name__ == "__main__":
261
+ unittest.main()
@@ -116,6 +116,64 @@ class TestSliceLastTurnsByUserBoundary:
116
116
  def test_non_list_returns_empty(self):
117
117
  assert slice_last_turns_by_user_boundary(None, 1) == []
118
118
 
119
+ # --- switchroom divergence: tool_result user-messages are NOT turns ---
120
+
121
+ @staticmethod
122
+ def _tool_result(tuid: str, text: str) -> dict:
123
+ # Claude Code emits tool results as role="user" with a content list of
124
+ # tool_result blocks (the shape read_transcript produces).
125
+ return {
126
+ "role": "user",
127
+ "content": [{"type": "tool_result", "tool_use_id": tuid, "content": text}],
128
+ }
129
+
130
+ def test_tool_result_messages_are_not_turn_boundaries(self):
131
+ # One human turn + 3 tool rounds. Requesting 1 turn must anchor to the
132
+ # human message, NOT the newest tool_result — otherwise a tool-heavy
133
+ # turn drops the human's text from the window (silent memory loss).
134
+ msgs = [
135
+ {"role": "user", "content": "human fact"},
136
+ {"role": "assistant", "content": "a"},
137
+ self._tool_result("t1", "r1"),
138
+ {"role": "assistant", "content": "a"},
139
+ self._tool_result("t2", "r2"),
140
+ {"role": "assistant", "content": "a"},
141
+ self._tool_result("t3", "r3"),
142
+ {"role": "assistant", "content": "a"},
143
+ ]
144
+ result = slice_last_turns_by_user_boundary(msgs, 1)
145
+ assert result[0]["content"] == "human fact"
146
+ assert result == msgs # only 1 human turn, so the whole thing is kept
147
+
148
+ def test_counts_human_turns_only_across_tool_heavy_turns(self):
149
+ # 2 human turns, each followed by a tool round. Requesting 1 human turn
150
+ # slices to the SECOND human message, not into the first turn's tools.
151
+ msgs = [
152
+ {"role": "user", "content": "human one"},
153
+ {"role": "assistant", "content": "a"},
154
+ self._tool_result("t1", "r1"),
155
+ {"role": "user", "content": "human two"},
156
+ {"role": "assistant", "content": "a"},
157
+ self._tool_result("t2", "r2"),
158
+ ]
159
+ result = slice_last_turns_by_user_boundary(msgs, 1)
160
+ assert result[0]["content"] == "human two"
161
+
162
+ def test_mixed_text_and_tool_result_block_is_a_boundary(self):
163
+ # A user message with BOTH text and a tool_result block still counts as
164
+ # a human turn (conservative — only pure tool_result messages are skipped).
165
+ msgs = [
166
+ {"role": "user", "content": "older"},
167
+ {"role": "assistant", "content": "a"},
168
+ {"role": "user", "content": [
169
+ {"type": "text", "text": "human with attached result"},
170
+ {"type": "tool_result", "tool_use_id": "t1", "content": "r1"},
171
+ ]},
172
+ {"role": "assistant", "content": "a"},
173
+ ]
174
+ result = slice_last_turns_by_user_boundary(msgs, 1)
175
+ assert result[0]["content"][0]["text"] == "human with attached result"
176
+
119
177
 
120
178
  # ---------------------------------------------------------------------------
121
179
  # compose_recall_query
@@ -159,6 +217,53 @@ class TestComposeRecallQuery:
159
217
  assert "user msg" in result
160
218
  assert "assistant msg" not in result
161
219
 
220
+ # --- switchroom divergence: the recall context slice counts HUMAN turns,
221
+ # not tool_result pseudo-turns (follow-up to #2830, closing the reviewer nit
222
+ # that the retain path had a test but the recall path — the OTHER caller of
223
+ # slice_last_turns_by_user_boundary — did not).
224
+
225
+ def test_tool_heavy_prior_turn_keeps_human_text_in_recall_context(self):
226
+ # A prior human turn stating a fact, then 3 sequential tool rounds
227
+ # (Claude Code emits tool results as role="user"). recall_context_turns=2
228
+ # asks for the latest turn + one prior HUMAN turn. If the tool_result
229
+ # messages were counted as boundaries the human fact would be sliced
230
+ # out; the guard skips them so the fact lands in "Prior context:".
231
+ messages = [
232
+ {"role": "user", "content": "my prod database is called ORCHID_PRIMARY"},
233
+ {"role": "assistant", "content": "let me look that up"},
234
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "TOOLPAYLOAD_1"}]},
235
+ {"role": "assistant", "content": "checking more"},
236
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "TOOLPAYLOAD_2"}]},
237
+ {"role": "assistant", "content": "one more"},
238
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t3", "content": "TOOLPAYLOAD_3"}]},
239
+ {"role": "assistant", "content": "here is your schema"},
240
+ ]
241
+ result = compose_recall_query("what port does it listen on", messages, recall_context_turns=2)
242
+ assert "Prior context:" in result
243
+ assert "ORCHID_PRIMARY" in result # human turn survived the tool-heavy turn
244
+ assert "TOOLPAYLOAD" not in result # tool_result payload is not human context
245
+
246
+ def test_recall_context_anchors_to_human_turns_across_tool_volume(self):
247
+ # Two prior human turns, each followed by tool rounds. Asking for 3
248
+ # context turns (latest + 2 prior HUMAN) must reach past all the
249
+ # tool_result messages to the oldest human turn — tool volume must not
250
+ # consume the turn budget.
251
+ messages = [
252
+ {"role": "user", "content": "the deploy key is FALCON_9_KEY"},
253
+ {"role": "assistant", "content": "looking"},
254
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out a"}]},
255
+ {"role": "assistant", "content": "more"},
256
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "out b"}]},
257
+ {"role": "user", "content": "and remind me of the region too"},
258
+ {"role": "assistant", "content": "checking region"},
259
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t3", "content": "out c"}]},
260
+ {"role": "assistant", "content": "region is ap-southeast-2"},
261
+ ]
262
+ result = compose_recall_query("put those together for me", messages, recall_context_turns=3)
263
+ assert "Prior context:" in result
264
+ assert "FALCON_9_KEY" in result
265
+ assert "and remind me of the region too" in result
266
+
162
267
 
163
268
  # ---------------------------------------------------------------------------
164
269
  # truncate_recall_query