fix(gateway): scope final-delivery flags to turn-final segment (#29346)
A streamed preamble ("Let me search...") finalized at a tool boundary
routed through _try_fresh_final, which unconditionally set
_final_response_sent=True even though it is a NON-final segment. The
gateway then reads that flag as "final delivered" and suppresses the
genuine final answer produced on the next API call, so the user silently
gets nothing. Only reproduces with fresh_final_after_seconds > 0.
- _try_fresh_final / _send_or_edit take is_turn_final; the segment-break
call site passes is_turn_final=got_done so only the turn-final answer
marks final-delivered.
- _reset_segment_state clears the final-delivery flags at every tool
boundary as defense-in-depth against any future premature setter.
- Failing-first regression + happy-path no-duplicate test.
This commit is contained in:
@@ -261,6 +261,12 @@ class GatewayStreamConsumer:
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
# #29346: a tool/segment boundary means what we delivered was an interim
|
||||
# preamble, not the final answer — clear the flags so a premature setter
|
||||
# can't fool the gateway. Safe: got_done returns before any reset, and
|
||||
# run.py reads these only after the consumer task exits.
|
||||
self._final_response_sent = False
|
||||
self._final_content_delivered = False
|
||||
# Native draft streaming: bump the draft_id so the next text segment
|
||||
# animates as a fresh preview below the tool-progress bubbles, not
|
||||
# over the prior segment's already-finalized draft. This is how
|
||||
@@ -549,6 +555,9 @@ class GatewayStreamConsumer:
|
||||
current_update_visible = await self._send_or_edit(
|
||||
display_text,
|
||||
finalize=(got_done or got_segment_break),
|
||||
# A segment-break finalize closes a preamble, not the
|
||||
# turn-final answer — only got_done marks delivered (#29346).
|
||||
is_turn_final=got_done,
|
||||
)
|
||||
self._last_edit_time = time.monotonic()
|
||||
|
||||
@@ -1058,12 +1067,17 @@ class GatewayStreamConsumer:
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
async def _try_fresh_final(self, text: str) -> bool:
|
||||
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
time. Returns True on successful delivery, False on any failure so
|
||||
the caller falls back to the normal edit path.
|
||||
|
||||
``is_turn_final`` is False when finalizing an interim segment at a tool
|
||||
boundary (a preamble) rather than the turn-final answer; the
|
||||
final-delivery flag is then left unset so the gateway still delivers the
|
||||
real answer from the next API call (#29346).
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
@@ -1108,10 +1122,13 @@ class GatewayStreamConsumer:
|
||||
self._message_created_ts = None
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
self._final_response_sent = True
|
||||
if is_turn_final:
|
||||
self._final_response_sent = True
|
||||
return True
|
||||
|
||||
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
|
||||
async def _send_or_edit(
|
||||
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
|
||||
) -> bool:
|
||||
"""Send or edit the streaming message.
|
||||
|
||||
Returns True if the text was successfully delivered (sent or edited),
|
||||
@@ -1205,7 +1222,9 @@ class GatewayStreamConsumer:
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and await self._try_fresh_final(text)
|
||||
and await self._try_fresh_final(
|
||||
text, is_turn_final=is_turn_final,
|
||||
)
|
||||
):
|
||||
return True
|
||||
# Edit existing message
|
||||
|
||||
Reference in New Issue
Block a user