@@ -78,6 +78,7 @@ def conversation_manager(request):
7878 ],
7979 ),
8080 # 5 - Remove dangling assistant message with tool use and user message without tool result
81+ # Must start with a user message, so we skip the assistant message
8182 (
8283 {"window_size" : 3 },
8384 [
@@ -87,7 +88,6 @@ def conversation_manager(request):
8788 {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "123" , "name" : "tool1" , "input" : {}}}]},
8889 ],
8990 [
90- {"role" : "assistant" , "content" : [{"text" : "First response" }]},
9191 {"role" : "user" , "content" : [{"text" : "Use a tool" }]},
9292 {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "123" , "name" : "tool1" , "input" : {}}}]},
9393 ],
@@ -107,34 +107,37 @@ def conversation_manager(request):
107107 ],
108108 ),
109109 # 7 - Message count above max window size - Preserve tool use/tool result pairs
110+ # Cannot start with assistant or orphaned toolResult, so trim advances to next plain user message
110111 (
111112 {"window_size" : 2 },
112113 [
113- {"role" : "user" , "content" : [{"toolResult " : { "toolUseId" : "123" , "content" : [], "status" : "success" } }]},
114+ {"role" : "user" , "content" : [{"text " : "Hello" }]},
114115 {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "123" , "name" : "tool1" , "input" : {}}}]},
115- {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "456" , "content" : [], "status" : "success" }}]},
116+ {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "123" , "content" : [], "status" : "success" }}]},
117+ {"role" : "assistant" , "content" : [{"text" : "Done" }]},
118+ {"role" : "user" , "content" : [{"text" : "Next" }]},
116119 ],
117120 [
118- {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "123" , "name" : "tool1" , "input" : {}}}]},
119- {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "456" , "content" : [], "status" : "success" }}]},
121+ {"role" : "user" , "content" : [{"text" : "Next" }]},
120122 ],
121123 ),
122124 # 8 - Test sliding window behavior - preserve tool use/result pairs across cut boundary
125+ # Must start with user message (not assistant, not orphaned toolResult), so trim advances to plain user msg
123126 (
124127 {"window_size" : 3 },
125128 [
126129 {"role" : "user" , "content" : [{"text" : "First message" }]},
127130 {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "123" , "name" : "tool1" , "input" : {}}}]},
128131 {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "123" , "content" : [], "status" : "success" }}]},
129132 {"role" : "assistant" , "content" : [{"text" : "Response after tool use" }]},
133+ {"role" : "user" , "content" : [{"text" : "Follow up" }]},
130134 ],
131135 [
132- {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "123" , "name" : "tool1" , "input" : {}}}]},
133- {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "123" , "content" : [], "status" : "success" }}]},
134- {"role" : "assistant" , "content" : [{"text" : "Response after tool use" }]},
136+ {"role" : "user" , "content" : [{"text" : "Follow up" }]},
135137 ],
136138 ),
137139 # 9 - Test sliding window with multiple tool pairs that need preservation
140+ # Must start with user message; orphaned toolResult is skipped, lands on plain user text
138141 (
139142 {"window_size" : 4 },
140143 [
@@ -144,11 +147,10 @@ def conversation_manager(request):
144147 {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "456" , "name" : "tool2" , "input" : {}}}]},
145148 {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "456" , "content" : [], "status" : "success" }}]},
146149 {"role" : "assistant" , "content" : [{"text" : "Final response" }]},
150+ {"role" : "user" , "content" : [{"text" : "Another question" }]},
147151 ],
148152 [
149- {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "456" , "name" : "tool2" , "input" : {}}}]},
150- {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "456" , "content" : [], "status" : "success" }}]},
151- {"role" : "assistant" , "content" : [{"text" : "Final response" }]},
153+ {"role" : "user" , "content" : [{"text" : "Another question" }]},
152154 ],
153155 ),
154156 ],
@@ -161,6 +163,43 @@ def test_apply_management(conversation_manager, messages, expected_messages):
161163 assert messages == expected_messages
162164
163165
166+ def test_sliding_window_forces_user_message_start ():
167+ """Test that trimmed conversation always starts with a user message (GitHub #2085)."""
168+ manager = SlidingWindowConversationManager (window_size = 3 , should_truncate_results = False )
169+ messages = [
170+ {"role" : "user" , "content" : [{"text" : "Hello" }]},
171+ {"role" : "assistant" , "content" : [{"text" : "Hi" }]},
172+ {"role" : "user" , "content" : [{"text" : "How are you?" }]},
173+ {"role" : "assistant" , "content" : [{"text" : "Good" }]},
174+ {"role" : "user" , "content" : [{"text" : "Great" }]},
175+ ]
176+ test_agent = Agent (messages = messages )
177+ manager .apply_management (test_agent )
178+
179+ assert len (messages ) == 3
180+ assert messages [0 ]["role" ] == "user"
181+ assert messages [0 ]["content" ] == [{"text" : "How are you?" }]
182+
183+
184+ def test_sliding_window_happy_path_preserves_window_size ():
185+ """In a typical user/assistant conversation, trimming preserves close to window_size messages."""
186+ manager = SlidingWindowConversationManager (window_size = 4 , should_truncate_results = False )
187+ messages = [
188+ {"role" : "user" , "content" : [{"text" : "First" }]},
189+ {"role" : "assistant" , "content" : [{"text" : "First response" }]},
190+ {"role" : "user" , "content" : [{"text" : "Second" }]},
191+ {"role" : "assistant" , "content" : [{"text" : "Second response" }]},
192+ {"role" : "user" , "content" : [{"text" : "Third" }]},
193+ {"role" : "assistant" , "content" : [{"text" : "Third response" }]},
194+ ]
195+ test_agent = Agent (messages = messages )
196+ manager .apply_management (test_agent )
197+
198+ assert len (messages ) == 4
199+ assert messages [0 ]["role" ] == "user"
200+ assert messages [0 ]["content" ] == [{"text" : "Second" }]
201+
202+
164203def test_sliding_window_conversation_manager_with_untrimmable_history_raises_context_window_overflow_exception ():
165204 manager = SlidingWindowConversationManager (1 , False )
166205 messages = [
@@ -171,7 +210,22 @@ def test_sliding_window_conversation_manager_with_untrimmable_history_raises_con
171210 test_agent = Agent (messages = messages )
172211
173212 with pytest .raises (ContextWindowOverflowException ):
174- manager .apply_management (test_agent )
213+ manager .reduce_context (test_agent , e = RuntimeError ("context overflow" ))
214+
215+ assert messages == original_messages
216+
217+
218+ def test_sliding_window_no_valid_trim_point_without_error_does_not_raise ():
219+ """When no valid trim point exists during routine management (no error), messages are left unchanged."""
220+ manager = SlidingWindowConversationManager (1 , False )
221+ messages = [
222+ {"role" : "assistant" , "content" : [{"toolUse" : {"toolUseId" : "456" , "name" : "tool1" , "input" : {}}}]},
223+ {"role" : "user" , "content" : [{"toolResult" : {"toolUseId" : "789" , "content" : [], "status" : "success" }}]},
224+ ]
225+ original_messages = messages .copy ()
226+ test_agent = Agent (messages = messages )
227+
228+ manager .apply_management (test_agent )
175229
176230 assert messages == original_messages
177231
0 commit comments