switchroom 0.17.0 → 0.17.1
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/cli/switchroom.js +3 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +164 -63
- package/telegram-plugin/format.ts +119 -17
- package/telegram-plugin/gateway/approvals-commands.ts +6 -2
- package/telegram-plugin/gateway/gateway.ts +17 -3
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
- package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
- package/telegram-plugin/tests/format-consistency.test.ts +79 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
- package/telegram-plugin/tests/welcome-text.test.ts +64 -0
- package/telegram-plugin/welcome-text.ts +13 -9
- package/vendor/hindsight-memory/CHANGELOG.md +42 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +39 -3
- package/vendor/hindsight-memory/scripts/retain.py +71 -10
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
- package/vendor/hindsight-memory/tests/test_content.py +105 -0
|
@@ -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
|