@@ -392,6 +392,7 @@ async function pipeStreamToClient(
392392 const { thinkingEnabled, imageTokenOverhead = 0 } = options
393393 const streamState : AnthropicStreamState = {
394394 messageStartSent : false ,
395+ messageStopSent : false ,
395396 contentBlockIndex : 0 ,
396397 contentBlockOpen : false ,
397398 thinkingBlockOpen : false ,
@@ -436,23 +437,7 @@ async function pipeStreamToClient(
436437 }
437438 }
438439
439- // If the upstream stream ended without ever sending a usable chunk
440- // (e.g. the model returned an empty response or only unsupported
441- // event types), no message_start was emitted and the client sees an
442- // empty SSE connection with no indication of what went wrong.
443- // Emit a synthetic Anthropic error so the UI can surface the problem.
444- if ( ! streamState . messageStartSent ) {
445- consola . warn (
446- "Copilot stream ended without producing any content — emitting error event" ,
447- )
448- const errorEvent = translateErrorToAnthropicErrorEvent (
449- "The model returned an empty response. This may indicate the model is unavailable or does not support this request." ,
450- )
451- await stream . writeSSE ( {
452- event : errorEvent . type ,
453- data : JSON . stringify ( errorEvent ) ,
454- } )
455- }
440+ await handleIncompleteStream ( stream , streamState )
456441 } catch ( error ) {
457442 consola . error ( "Stream error from Copilot:" , error )
458443
@@ -480,6 +465,84 @@ async function pipeStreamToClient(
480465 }
481466}
482467
468+ /**
469+ * Handles the case where the upstream stream ended without a proper Anthropic
470+ * termination sequence (message_delta + message_stop).
471+ *
472+ * Two scenarios:
473+ * 1. Stream never produced any content → emit a synthetic error event.
474+ * 2. Stream started (message_start sent) but ended without finish_reason →
475+ * synthesize the missing termination events so Claude Code can proceed.
476+ */
477+ async function handleIncompleteStream (
478+ stream : SSEStreamingApi ,
479+ state : AnthropicStreamState ,
480+ ) : Promise < void > {
481+ if ( ! state . messageStartSent ) {
482+ // No usable chunks arrived at all.
483+ consola . warn (
484+ "Copilot stream ended without producing any content — emitting error event" ,
485+ )
486+ const errorEvent = translateErrorToAnthropicErrorEvent (
487+ "The model returned an empty response. This may indicate the model is unavailable or does not support this request." ,
488+ )
489+ await stream . writeSSE ( {
490+ event : errorEvent . type ,
491+ data : JSON . stringify ( errorEvent ) ,
492+ } )
493+ return
494+ }
495+
496+ if ( state . messageStopSent ) {
497+ return // Stream ended normally, nothing to do.
498+ }
499+
500+ // The upstream stream started but ended without a chunk containing
501+ // finish_reason — no message_delta / message_stop was ever sent.
502+ // Some models (notably Gemini) can terminate the stream abruptly after
503+ // emitting content or tool-call chunks. Without a proper termination
504+ // sequence Claude Code sees the SSE connection close with no indication
505+ // of completion and treats the turn as abandoned / silently dead.
506+ consola . warn (
507+ "Copilot stream ended without finish_reason — synthesizing message_delta/message_stop" ,
508+ )
509+
510+ if ( state . contentBlockOpen ) {
511+ await stream . writeSSE ( {
512+ event : "content_block_stop" ,
513+ data : JSON . stringify ( {
514+ type : "content_block_stop" ,
515+ index : state . contentBlockIndex ,
516+ } ) ,
517+ } )
518+ }
519+
520+ // Determine the correct stop_reason: if tool calls were emitted
521+ // during the stream, the model intended "tool_use"; otherwise
522+ // default to "end_turn".
523+ const hasToolCalls = Object . keys ( state . toolCalls ) . length > 0
524+ const stopReason = hasToolCalls ? "tool_use" : "end_turn"
525+
526+ await stream . writeSSE ( {
527+ event : "message_delta" ,
528+ data : JSON . stringify ( {
529+ type : "message_delta" ,
530+ delta : {
531+ stop_reason : stopReason ,
532+ stop_sequence : null ,
533+ } ,
534+ usage : {
535+ input_tokens : 0 ,
536+ output_tokens : 0 ,
537+ } ,
538+ } ) ,
539+ } )
540+ await stream . writeSSE ( {
541+ event : "message_stop" ,
542+ data : JSON . stringify ( { type : "message_stop" } ) ,
543+ } )
544+ }
545+
483546const isNonStreaming = (
484547 response : Awaited < ReturnType < typeof createChatCompletions > > ,
485548) : response is ChatCompletionResponse => Object . hasOwn ( response , "choices" )
0 commit comments