@@ -128,6 +128,14 @@ async def _agent_run_to_vercel_parts_impl(
128128 usage : Optional [Dict [str , Any ]] = None
129129 stop_reason : Optional [str ] = None
130130 content_parts_emitted = 0
131+ # Whether a real `error` frame already went out this turn (a live-streamed provider/runner
132+ # error, or one raised out of the run and caught below). The zero-content-parts backstop
133+ # below must not pile a generic "produced no output" frame on top of a real error the user
134+ # already saw -- that would bury the actionable message under a useless one (the runner's
135+ # swallowed-provider-error recovery streams the real error live AND fails the terminal
136+ # result, so both an `etype == "error"` event and the `except` branch can fire for the SAME
137+ # underlying failure; either one must suppress the backstop).
138+ error_emitted = False
131139 # Tool-call ids already surfaced as a tool part. An approval request attaches
132140 # to its tool part by id, so we synthesize one only when none preceded it.
133141 seen_tool_calls : set = set ()
@@ -285,6 +293,7 @@ async def _agent_run_to_vercel_parts_impl(
285293 elif etype == "usage" :
286294 usage = _usage_metadata (data )
287295 elif etype == "error" :
296+ error_emitted = True
288297 yield {"type" : "error" , "errorText" : data .get ("message" , "" )}
289298 elif etype == "done" :
290299 # Last non-null stop reason wins; see the routing-layer twin's `done` note.
@@ -294,7 +303,15 @@ async def _agent_run_to_vercel_parts_impl(
294303 except Exception as exc :
295304 # Sanitize — an unexpected exception's raw str() can carry a stack/path dump.
296305 log .error ("agent_run_to_vercel_parts: error mid-stream" , exc_info = True )
297- yield {"type" : "error" , "errorText" : sanitize_runner_error (exc )}
306+ if not error_emitted :
307+ # Only surface this as a NEW user-facing frame when no error already went out this
308+ # turn. A swallowed-provider-error recovery streams the real error live (the
309+ # `etype == "error"` branch above) and THEN fails the terminal result, so this
310+ # exception is very often just that same failure resurfacing as a raised
311+ # `RuntimeError` (`result_from_wire`) -- yielding it too would duplicate the message
312+ # the user already saw under a second, "Agent run failed: ..."-prefixed frame.
313+ yield {"type" : "error" , "errorText" : sanitize_runner_error (exc )}
314+ error_emitted = True
298315 finally :
299316 # Every exit path — including the raw exception above — must still drain to a
300317 # finish frame, or a consumer waiting on it hangs. Mirrors the routing-layer twin.
@@ -312,8 +329,12 @@ async def _agent_run_to_vercel_parts_impl(
312329 trace_id = result .trace_id
313330
314331 yield {"type" : "finish-step" }
315- if content_parts_emitted == 0 :
332+ if content_parts_emitted == 0 and not error_emitted :
316333 # An ok:true run with zero content parts would otherwise render as a blank bubble.
334+ # Skip this when a real error already went out above -- appending a second, useless
335+ # "no output" frame on top of it would bury the actionable message (the swallowed-
336+ # provider-error path both streams a live error event AND fails the terminal result,
337+ # so this backstop must not double up on it).
317338 yield {"type" : "error" , "errorText" : "The agent produced no output." }
318339 finish : Dict [str , Any ] = {"type" : "finish" }
319340 finish_reason = _map_finish_reason (stop_reason )
@@ -380,6 +401,9 @@ async def _agent_stream_to_vercel_stream_impl(
380401 usage : Optional [Dict [str , Any ]] = None
381402 stop_reason : Optional [str ] = None
382403 content_parts_emitted = 0
404+ # See the dev-twin's `error_emitted` note above: suppresses the zero-content backstop when
405+ # a real error already went out this turn, live or raised.
406+ error_emitted = False
383407 seen_tool_calls : set = set ()
384408 tool_names_by_id : Dict [Any , Any ] = {}
385409
@@ -535,6 +559,7 @@ async def _agent_stream_to_vercel_stream_impl(
535559 elif etype == "usage" :
536560 usage = _usage_metadata (data )
537561 elif etype == "error" :
562+ error_emitted = True
538563 yield {"type" : "error" , "errorText" : data .get ("message" , "" )}
539564 elif etype == "done" :
540565 # Prefer the LAST non-null stop reason. The handler appends a corrective
@@ -547,13 +572,23 @@ async def _agent_stream_to_vercel_stream_impl(
547572 stop_reason = reason
548573 except Exception as exc :
549574 log .error ("agent_stream_to_vercel_stream: error mid-stream" , exc_info = True )
550- yield {"type" : "error" , "errorText" : sanitize_runner_error (exc )}
575+ if not error_emitted :
576+ # See the dev-twin's matching note: suppress this when a real error already went
577+ # out live this turn, so a swallowed-provider-error recovery (live error event, then
578+ # a failed terminal result raised as this same exception) doesn't duplicate the
579+ # user-facing message under a second, differently-worded frame.
580+ yield {"type" : "error" , "errorText" : sanitize_runner_error (exc )}
581+ error_emitted = True
551582 finally :
552583 # Every exit path — including the raw exception above — must still drain to a
553584 # finish frame, or a consumer waiting on it hangs.
554585 yield {"type" : "finish-step" }
555- if content_parts_emitted == 0 :
586+ if content_parts_emitted == 0 and not error_emitted :
556587 # An ok:true run with zero content parts would otherwise render as a blank bubble.
588+ # Skip this when a real error already went out above (see the dev-twin's matching
589+ # note) -- a swallowed-provider-error turn both streams a live error event and fails
590+ # the terminal result, so this backstop must not double up on it and bury the real
591+ # message under "The agent produced no output."
557592 yield {"type" : "error" , "errorText" : "The agent produced no output." }
558593 finish : Dict [str , Any ] = {"type" : "finish" }
559594 finish_reason = _map_finish_reason (stop_reason )
0 commit comments