fix(agent): preserve streamed refusals as text (port of anomalyco/opencode#43343)
A model that declines mid-stream delivers the explanation on the structured refusal channel (chat_completions delta.refusal; Responses response.refusal.delta / refusal content parts) and leaves content empty. The streaming accumulators dropped that channel entirely, so a streamed refusal assembled into an empty message and fell into the empty/invalid-response retry loops - burning paid retries reproducing a deterministic refusal - while the non-streaming path had already fixed this class in #46013. - chat_completions streaming: accumulate delta.refusal (incl. model_extra), expose message.refusal on the assembled mock response so ChatCompletionsTransport.normalize_response applies the existing sole-payload -> content_filter promotion; count refusal deltas in the zero-chunk guard; carry refusal in the Relay final-response dict. - Codex Responses stream consumer: collect response.refusal.delta as answer text so a refusal-only stream no longer raises 'did not emit a terminal response' with zero usable content. - Responses normalizer: read type=refusal content parts in _extract_responses_message_text (attr and dict shapes). Sabotage-verified: each new test fails with its wiring line disabled. E2E: refusal-only stream -> terminal content_filter with explanation; refusal-alongside-content stays a normal usable turn; plain-text streams unchanged.
This commit is contained in:
@@ -656,6 +656,15 @@ class _CodexResponseAssembler:
|
||||
self._safe(self.on_first_delta, "on_first_delta")
|
||||
self._safe(self.on_text_delta, "on_text_delta", delta_text)
|
||||
|
||||
def _on_refusal_delta(self, event: Any, event_type: str) -> None:
|
||||
# ``response.refusal.delta``: the model declined and streams its explanation on the refusal
|
||||
# channel instead of output_text. It is answer text — a refusal-only stream must not end
|
||||
# with zero content and "did not emit a terminal response". The done item's ``refusal``
|
||||
# part is read by the normalizer; the deltas cover backends that omit the done item.
|
||||
refusal_text = _event_field(event, "delta", "")
|
||||
if isinstance(refusal_text, str) and refusal_text:
|
||||
self.text_deltas.append(refusal_text)
|
||||
|
||||
def _on_function_call(self, event: Any, event_type: str) -> None:
|
||||
self.has_tool_calls = True
|
||||
pending = self.pending_function_calls.get(str(_event_field(event, "item_id", "")))
|
||||
@@ -723,6 +732,7 @@ class _CodexResponseAssembler:
|
||||
"error": lambda self, event, event_type: _raise_stream_error(event),
|
||||
"response.output_item.added": _on_item_added, "response.output_item.done": _on_item_done,
|
||||
"response.completed": _on_terminal, "response.incomplete": _on_terminal, "response.failed": _on_terminal,
|
||||
"response.refusal.delta": _on_refusal_delta,
|
||||
}
|
||||
_FUZZY_HANDLERS = (
|
||||
(lambda t: "output_text.delta" in t, _on_text_delta), (lambda t: "function_call" in t, _on_function_call),
|
||||
|
||||
Reference in New Issue
Block a user