@@ -141,3 +141,58 @@ def test_never_finishing_raises_after_max_turns(tmp_path):
141141 adapter = _FakeAdapter ([_Resp ((TextBlock (text = "thinking" ),)) for _ in range (MAX_TURNS )])
142142 with pytest .raises (RuntimeError ):
143143 explore_repository (_repo (tmp_path ), _FakeBinding (adapter ), "sys" , "task" , _FINISH )
144+
145+
146+ class _ScriptedRaisingAdapter (_FakeAdapter ):
147+ """Scripted entries may be exceptions: a completion that RAISES (an empty/
148+ malformed turn -- every adapter raises LLMResponseError on empty content).
149+ """
150+ def complete (self , * , model , system , messages , max_tokens , tools ):
151+ self .seen_messages .append (list (messages ))
152+ item = self ._scripted .pop (0 )
153+ if isinstance (item , BaseException ):
154+ raise item
155+ return item
156+
157+
158+ def test_empty_turn_is_recovered_not_fatal (tmp_path ):
159+ # An empty/malformed turn (adapter raises LLMResponseError) mid-survey must NOT
160+ # abort a survey that may already have read useful context. The loop consumes
161+ # the turn and retries; a later valid finish still succeeds.
162+ # Raise the exact class repo_explorer's `except` is bound to (its own module
163+ # binding) so the test is stable even if another test purged utilities.* from
164+ # sys.modules and re-minted a second LLMResponseError identity.
165+ from context .repo_explorer import LLMResponseError
166+ adapter = _ScriptedRaisingAdapter ([
167+ LLMResponseError ("OpenAI returned an empty completion" ),
168+ _Resp ((ToolUseBlock (id = "tu-fin" , name = "finish" , input = {"ok" : 1 }),)),
169+ ])
170+ payload , budget = explore_repository (_repo (tmp_path ), _FakeBinding (adapter ),
171+ "sys" , "task" , _FINISH )
172+ assert payload == {"ok" : 1 }
173+ assert budget .turns == 2 # the empty turn was consumed, then finish
174+
175+
176+ def test_persistent_empty_turns_fail_loud_and_bounded (tmp_path ):
177+ # A model that returns nothing on EVERY turn must still fail loudly (never a
178+ # silent partial) and bounded (not burn the entire MAX_TURNS budget).
179+ from context .repo_explorer import LLMResponseError
180+ adapter = _ScriptedRaisingAdapter (
181+ [LLMResponseError ("empty" ) for _ in range (MAX_TURNS + 2 )])
182+ with pytest .raises (LLMResponseError ):
183+ explore_repository (_repo (tmp_path ), _FakeBinding (adapter ), "sys" , "task" , _FINISH )
184+ # Bailed early on consecutive empties -- did NOT consume the whole budget.
185+ assert len (adapter .seen_messages ) < MAX_TURNS
186+
187+
188+ def test_refusal_is_not_retried_propagates (tmp_path ):
189+ # A refusal/content-filter (LLMRefusalError, subclass of LLMResponseError) is
190+ # NOT a transient blank: it must propagate immediately, never be churned past.
191+ from context .repo_explorer import LLMRefusalError
192+ adapter = _ScriptedRaisingAdapter ([
193+ LLMRefusalError ("model refused" ),
194+ _Resp ((ToolUseBlock (id = "tu-fin" , name = "finish" , input = {"ok" : 1 }),)),
195+ ])
196+ with pytest .raises (LLMRefusalError ):
197+ explore_repository (_repo (tmp_path ), _FakeBinding (adapter ), "sys" , "task" , _FINISH )
198+ assert len (adapter .seen_messages ) == 1 # bailed on the refusal, did not retry
0 commit comments