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:
Hermes Agent
2026-08-20 17:21:10 -07:00
committed by Teknium
parent 021ab58a23
commit e27f16365d
6 changed files with 198 additions and 6 deletions
+10
View File
@@ -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),