@miller-tech/uap 1.220.9 → 1.220.11

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.
@@ -26,6 +26,10 @@ def _load_proxy_module():
26
26
 
27
27
  proxy = _load_proxy_module()
28
28
 
29
+ # Angle brackets built at runtime; see _markup below for why.
30
+ LT = chr(60)
31
+ GT = chr(62)
32
+
29
33
 
30
34
  def _looping_monitor(fires_so_far: int):
31
35
  mon = proxy.SessionMonitor()
@@ -83,5 +87,329 @@ class TestStuckBreakHard(unittest.TestCase):
83
87
  self.assertLessEqual(proxy.PROXY_STUCK_BREAK_HARD_FIRES, 5)
84
88
 
85
89
 
90
+ class TestSuppressedTurnEndsInProse(unittest.TestCase):
91
+ """The response half of the hard break.
92
+
93
+ Suppressing XML resurrection stops the loop re-arming, but on its own it
94
+ ships the raw markup to the client as the assistant's visible text.
95
+ Measured live 2026-08-25 (opencode ses_fc7a27ea...): the client rendered a
96
+ <tool_call> block as the reply, logged "exiting loop", and the operator
97
+ retyped "go" straight back into the same loop. A turn forced to end in
98
+ prose must actually end in prose.
99
+ """
100
+
101
+ @staticmethod
102
+ def _markup(envelope=True, closed=True, lead=""):
103
+ # Built from chr() so the literal tags never appear in this file --
104
+ # the repo's bash-safety enforcer refuses commands carrying standalone
105
+ # tool-call tag lines, which makes a literal fixture unrunnable.
106
+ def tag(name):
107
+ return LT + name + GT
108
+ parts = [tag("function=bash"), tag("parameter=command"), "grep -rn X ."]
109
+ if closed:
110
+ parts += [tag("/parameter"), tag("/function")]
111
+ if envelope:
112
+ parts = [tag("tool_call")] + parts + [tag("/tool_call")]
113
+ return (lead + "\n" + "\n".join(parts)) if lead else "\n".join(parts)
114
+
115
+ @staticmethod
116
+ def _resp(text):
117
+ return {
118
+ "choices": [
119
+ {"finish_reason": "stop", "message": {"role": "assistant", "content": text}}
120
+ ]
121
+ }
122
+
123
+ def test_enveloped_markup_never_reaches_the_client(self):
124
+ out = proxy._maybe_extract_text_tool_calls(
125
+ self._resp(self._markup()), suppress=True
126
+ )
127
+ content = out["choices"][0]["message"]["content"]
128
+ self.assertNotIn(LT + "tool_call" + GT, content)
129
+ self.assertNotIn(LT + "function=", content)
130
+ self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
131
+
132
+ def test_bare_hermes_block_is_stripped_too(self):
133
+ # _strip_residual_tool_call_xml only knows the envelope; a bare
134
+ # function block walked straight through it.
135
+ out = proxy._maybe_extract_text_tool_calls(
136
+ self._resp(self._markup(envelope=False)), suppress=True
137
+ )
138
+ self.assertNotIn(LT + "function=", out["choices"][0]["message"]["content"])
139
+
140
+ def test_unclosed_block_is_stripped_too(self):
141
+ out = proxy._maybe_extract_text_tool_calls(
142
+ self._resp(self._markup(envelope=False, closed=False)), suppress=True
143
+ )
144
+ content = out["choices"][0]["message"]["content"]
145
+ self.assertNotIn(LT + "function=", content)
146
+ self.assertNotIn(LT + "parameter=", content)
147
+
148
+ def test_markup_only_reply_gets_fallback_prose(self):
149
+ # An empty assistant message is no better than XML: the client ends the
150
+ # turn either way and the operator sees a blank reply.
151
+ out = proxy._maybe_extract_text_tool_calls(
152
+ self._resp(self._markup()), suppress=True
153
+ )
154
+ self.assertEqual(
155
+ out["choices"][0]["message"]["content"], proxy.STUCK_BREAK_PROSE_FALLBACK
156
+ )
157
+
158
+ def test_the_model_own_prose_is_kept_when_it_wrote_any(self):
159
+ out = proxy._maybe_extract_text_tool_calls(
160
+ self._resp(self._markup(lead="I will check the DB.")), suppress=True
161
+ )
162
+ self.assertEqual(out["choices"][0]["message"]["content"], "I will check the DB.")
163
+
164
+ def test_plain_prose_passes_through_untouched(self):
165
+ # Whitespace and blank lines included: the sanitiser collapses those, so
166
+ # a fixture without them is a fixed point and would pass even if the
167
+ # early exit were deleted.
168
+ text = "\n The cleanup at line 725 takes\n\n\n the lock in the wrong order. \n"
169
+ out = proxy._maybe_extract_text_tool_calls(self._resp(text), suppress=True)
170
+ self.assertEqual(out["choices"][0]["message"]["content"], text)
171
+
172
+ def test_suppression_still_refuses_to_resurrect_the_call(self):
173
+ # The original guarantee must survive the sanitiser.
174
+ out = proxy._maybe_extract_text_tool_calls(
175
+ self._resp(self._markup()), suppress=True
176
+ )
177
+ self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
178
+ self.assertEqual(out["choices"][0]["finish_reason"], "stop")
179
+
180
+ def test_unsuppressed_turns_still_promote_the_call(self):
181
+ # The sanitiser must not leak onto the normal path, where recovering a
182
+ # prose tool call is the whole point.
183
+ out = proxy._maybe_extract_text_tool_calls(
184
+ self._resp(self._markup()), suppress=False
185
+ )
186
+ calls = out["choices"][0]["message"].get("tool_calls") or []
187
+ self.assertEqual([c["function"]["name"] for c in calls], ["bash"])
188
+ self.assertEqual(out["choices"][0]["finish_reason"], "tool_calls")
189
+
190
+ def test_end_to_end_the_anthropic_response_is_a_prose_end_turn(self):
191
+ # BARE hermes, not the enveloped form: the pre-existing
192
+ # _strip_residual_tool_call_xml already deleted a whole <tool_call>
193
+ # envelope downstream, so the enveloped fixture passed on the OLD code
194
+ # too -- with an EMPTY text block, the very outcome this rail rejects.
195
+ resp = self._resp(self._markup(envelope=False))
196
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
197
+ out = proxy.openai_to_anthropic_response(
198
+ resp, "qwen", suppress_text_tool_extraction=True
199
+ )
200
+ self.assertEqual(out["stop_reason"], "end_turn")
201
+ blocks = out.get("content") or []
202
+ self.assertTrue(blocks, "a suppressed turn must still carry a text block")
203
+ self.assertTrue(all(b.get("type") == "text" for b in blocks))
204
+ joined = "".join(b.get("text") or "" for b in blocks)
205
+ # An empty reply is as useless to the client as the markup was.
206
+ self.assertTrue(joined.strip(), "the turn must end in ACTUAL prose")
207
+ self.assertNotIn(LT + "function=", joined)
208
+ self.assertNotIn(LT + "tool_call" + GT, joined)
209
+
210
+
86
211
  if __name__ == "__main__":
87
212
  unittest.main()
213
+
214
+ class TestSuppressedMarkupLeakClasses(unittest.TestCase):
215
+ """Every markup shape that reached a client, and every prose shape that must not be eaten.
216
+
217
+ The sanitiser deletes text, so its failure modes run both ways: markup left
218
+ behind is the original bug, and prose destroyed is a new one. Both are
219
+ pinned here.
220
+ """
221
+
222
+ @staticmethod
223
+ def _clean(text):
224
+ return proxy._strip_all_tool_call_markup(text)
225
+
226
+ @staticmethod
227
+ def _tag(name):
228
+ return LT + name + GT
229
+
230
+ def _block(self, name="bash", closed=True, param=True):
231
+ parts = [self._tag(f"function={name}")]
232
+ if param:
233
+ parts += [self._tag("parameter=command"), "grep X ."]
234
+ if closed:
235
+ parts.append(self._tag("/parameter"))
236
+ else:
237
+ parts.append("body")
238
+ if closed:
239
+ parts.append(self._tag("/function"))
240
+ return "".join(parts)
241
+
242
+ # --- markup that must go ---
243
+
244
+ def test_strips_a_dotted_or_hyphenated_tool_name(self):
245
+ # The PARSER's name class is [A-Za-z_][A-Za-z0-9_]*, so it ignored these
246
+ # -- and the old stripper reused it, leaving a half-eaten block behind.
247
+ for name in ("web.search", "read-file", "2fa_check"):
248
+ with self.subTest(name=name):
249
+ self.assertEqual(self._clean(self._block(name)), "")
250
+
251
+ def test_strips_an_unclosed_block_that_carries_a_parameter(self):
252
+ self.assertEqual(self._clean(self._block(closed=False)), "")
253
+
254
+ def test_strips_an_orphan_closing_tag(self):
255
+ self.assertEqual(self._clean("All done." + self._tag("/function")), "All done.")
256
+ self.assertEqual(self._clean("Done." + self._tag("/tool_call")), "Done.")
257
+ self.assertEqual(self._clean("Done." + self._tag("/parameter")), "Done.")
258
+
259
+ def test_strips_an_unclosed_gemma_dsl_call(self):
260
+ # The Gemma parsing regex has no premature-EOS arm, so a truncated call
261
+ # passed through whole.
262
+ dsl = LT + "|tool_call" + GT + "call: bash {c:1}"
263
+ self.assertEqual(self._clean("Done.\n" + dsl), "Done.")
264
+
265
+ # --- prose that must survive ---
266
+
267
+ def test_prose_that_merely_mentions_a_tag_keeps_its_sentence(self):
268
+ # The hard-tier directive asks the model what the repeated call
269
+ # returned, i.e. it invites naming the call. A \Z-anchored delete turned
270
+ # that into "The proxy scans for".
271
+ text = (
272
+ "The proxy scans for "
273
+ + self._tag("function=bash")
274
+ + " and then deletes the rest. THIS SHOULD SURVIVE."
275
+ )
276
+ out = self._clean(text)
277
+ self.assertIn("THIS SHOULD SURVIVE.", out)
278
+ self.assertIn("The proxy scans for", out)
279
+
280
+ def test_prose_between_two_blocks_survives(self):
281
+ text = (
282
+ "Alpha.\n"
283
+ + self._block("b")
284
+ + "\nBETWEEN-PROSE\n"
285
+ + self._block("d")
286
+ + "\nOmega."
287
+ )
288
+ out = self._clean(text)
289
+ self.assertIn("BETWEEN-PROSE", out)
290
+ self.assertIn("Alpha.", out)
291
+ self.assertIn("Omega.", out)
292
+
293
+ def test_a_malformed_block_does_not_swallow_the_next_blocks_prose(self):
294
+ # One unclosed opener used to match through the NEXT block's </function>.
295
+ text = (
296
+ "Alpha.\n"
297
+ + self._tag("function=bash")
298
+ + "X\nIMPORTANT PROSE\n"
299
+ + self._block("read")
300
+ + "\nOmega."
301
+ )
302
+ out = self._clean(text)
303
+ self.assertIn("IMPORTANT PROSE", out)
304
+ self.assertNotIn(LT + "function=", out)
305
+
306
+ def test_a_fenced_json_call_is_deliberately_left_alone(self):
307
+ # The normal path schema-matches these; here they are indistinguishable
308
+ # from a model quoting JSON, and destroying real output is worse.
309
+ text = 'I will run it.\n\n```json\n{"name": "bash"}\n```'
310
+ self.assertEqual(self._clean(text), text)
311
+
312
+
313
+ class TestSuppressedSanitiserReach(unittest.TestCase):
314
+ """Fields and shapes the sanitiser has to reach, beyond choices[0].content."""
315
+
316
+ @staticmethod
317
+ def _markup():
318
+ return (
319
+ LT + "function=bash" + GT + LT + "parameter=command" + GT
320
+ + "grep X ." + LT + "/parameter" + GT + LT + "/function" + GT
321
+ )
322
+
323
+ def test_scrubs_the_reasoning_sidecar_too(self):
324
+ # With content empty, the EMPTY-OUTPUT GUARD promotes reasoning into the
325
+ # VISIBLE text -- leaving it alone just relocates the leak.
326
+ resp = {"choices": [{"message": {"role": "assistant", "content": "", "reasoning_content": self._markup()}}]}
327
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
328
+ self.assertNotIn(LT + "function=", resp["choices"][0]["message"]["reasoning_content"])
329
+
330
+ def test_a_think_wrapped_call_still_yields_visible_prose(self):
331
+ # "<think></think>" is truthy but renders blank. Emptiness has to be
332
+ # judged on what survives thinking extraction.
333
+ text = LT + "think" + GT + LT + "/think" + GT + self._markup()
334
+ resp = {"choices": [{"message": {"role": "assistant", "content": text}}]}
335
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
336
+ _, visible = proxy._extract_thinking_block(resp["choices"][0]["message"]["content"])
337
+ self.assertTrue(visible.strip(), "a think-only reply renders blank to the client")
338
+
339
+ def test_scrubs_every_choice_not_just_the_first(self):
340
+ resp = {"choices": [
341
+ {"message": {"role": "assistant", "content": "fine"}},
342
+ {"message": {"role": "assistant", "content": self._markup()}},
343
+ ]}
344
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
345
+ self.assertNotIn(LT + "function=", resp["choices"][1]["message"]["content"])
346
+
347
+ def test_leaves_a_message_that_has_real_tool_calls_alone(self):
348
+ # Rewriting the text beside a live tool call would attach an apology to
349
+ # a turn that is actually doing work.
350
+ mk = self._markup()
351
+ resp = {"choices": [{"message": {"role": "assistant", "content": mk, "tool_calls": [{"id": "x"}]}}]}
352
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
353
+ self.assertEqual(resp["choices"][0]["message"]["content"], mk)
354
+
355
+ def test_survives_a_malformed_upstream_payload(self):
356
+ # A degraded turn must not become a 500.
357
+ for bad in ({"choices": [{"message": None}]}, {"choices": ["oops"]}, {"choices": []}, {}):
358
+ with self.subTest(bad=bad):
359
+ proxy._maybe_extract_text_tool_calls(dict(bad), suppress=True)
360
+
361
+
362
+ class TestSuppressedTurnIsBuffered(unittest.TestCase):
363
+ """A tools-stripped turn must not be streamed.
364
+
365
+ The sanitiser only runs on the buffered path -- a streamed turn is already
366
+ on the wire before anything can inspect it. And the hard tier pops
367
+ `tool_choice`, which is precisely the key _should_use_guarded_non_stream
368
+ requires, so stripping the tools ALSO routed the turn away from the guarded
369
+ path. That is why the request handler ORs the monitor flag in.
370
+ """
371
+
372
+ @staticmethod
373
+ def _hard_tier_body():
374
+ # What _maybe_inject_stuck_break leaves behind at the hard tier: no
375
+ # tools, no tool_choice, no grammar.
376
+ return {"messages": [{"role": "user", "content": "go"}]}
377
+
378
+ @staticmethod
379
+ def _monitor(suppress):
380
+ mon = proxy.SessionMonitor()
381
+ mon.suppress_text_tool_extraction = suppress
382
+ return mon
383
+
384
+ def test_a_suppressed_turn_is_always_buffered(self):
385
+ # Regardless of stream flag, body shape, or any ambient proxy config:
386
+ # the sanitiser only runs on the buffered path.
387
+ for is_stream in (True, False):
388
+ with self.subTest(is_stream=is_stream):
389
+ self.assertTrue(
390
+ proxy._should_buffer_turn(
391
+ is_stream,
392
+ {"stream": is_stream},
393
+ self._hard_tier_body(),
394
+ self._monitor(True),
395
+ )
396
+ )
397
+
398
+ def test_an_ordinary_turn_defers_to_the_existing_check(self):
399
+ # The new reason must ADD to the old rule, never replace it.
400
+ body = self._hard_tier_body()
401
+ mon = self._monitor(False)
402
+ self.assertEqual(
403
+ proxy._should_buffer_turn(True, {"stream": True}, body, mon),
404
+ proxy._should_use_guarded_non_stream(True, {"stream": True}, body),
405
+ )
406
+
407
+ def test_suppression_overrides_a_declining_guarded_check(self):
408
+ # Pin the actual defect: with the flag off this exact turn is NOT
409
+ # buffered by the old rule alone, and with it on it is.
410
+ body = {"stream": True}
411
+ openai_body = self._hard_tier_body()
412
+ if proxy._should_use_guarded_non_stream(True, body, openai_body):
413
+ self.skipTest("ambient proxy config already forces buffering here")
414
+ self.assertFalse(proxy._should_buffer_turn(True, body, openai_body, self._monitor(False)))
415
+ self.assertTrue(proxy._should_buffer_turn(True, body, openai_body, self._monitor(True)))