@@ -682,7 +682,8 @@ describe("Responses bridge reasoning and usage parity", () => {
682682 test ( "heartbeat events reset the stall watchdog and emit no protocol frame" , async ( ) => {
683683 // Regression for the Cursor parallel-tool-call stall: while the upstream silently assembles tool
684684 // calls, the adapter emits `heartbeat` events. They must keep the stall watchdog alive (no
685- // upstream_stall_timeout) without producing any Responses protocol event of their own.
685+ // upstream_stall_timeout). Adapter heartbeats themselves are not translated into Responses
686+ // protocol items; wire keepalives use a separate `response.heartbeat` frame (see next test).
686687 async function * heartbeatsThenDone ( ) : AsyncGenerator < AdapterEvent > {
687688 // More heartbeats than maxStallTicks would allow if they did NOT reset the counter.
688689 for ( let i = 0 ; i < 6 ; i ++ ) {
@@ -699,9 +700,29 @@ describe("Responses bridge reasoning and usage parity", () => {
699700 ) ) ;
700701 expect ( frames . some ( f => ( f . data . response as Record < string , unknown > | undefined ) ?. incomplete_details ) ) . toBe ( false ) ;
701702 expect ( frames . some ( f => f . event === "response.completed" ) ) . toBe ( true ) ;
702- // No protocol frame is produced by a heartbeat itself (only created/text/completed appear) .
703+ // Adapter heartbeats must not be mis-translated into a rich protocol event of their own .
703704 expect ( frames . some ( f => f . event === "response.heartbeat" && f . data . type === "heartbeat" && Object . keys ( f . data ) . length > 2 ) ) . toBe ( false ) ;
704705 } ) ;
706+
707+ test ( "wire response.heartbeat keeps firing while only adapter heartbeats flow" , async ( ) => {
708+ // Issue #521: web-search buffers semantic events and yields invisible adapter heartbeats from
709+ // raw-byte progress. Those must not suppress wire keepalives, or Codex Desktop idle-timeouts
710+ // (~5 min) while OCX still considers the upstream alive.
711+ async function * adapterHeartbeatsOnly ( ) : AsyncGenerator < AdapterEvent > {
712+ for ( let i = 0 ; i < 8 ; i ++ ) {
713+ yield { type : "heartbeat" } ;
714+ await new Promise ( r => setTimeout ( r , 15 ) ) ;
715+ }
716+ yield { type : "text_delta" , text : "ok" } ;
717+ yield { type : "done" } ;
718+ }
719+ const frames = await collectSse ( bridgeToResponsesSSE (
720+ adapterHeartbeatsOnly ( ) , "model" , undefined , undefined , undefined , undefined , 10 , { stallTimeoutSec : 1 } ,
721+ ) ) ;
722+ expect ( frames . some ( f => f . event === "response.heartbeat" && f . data . type === "response.heartbeat" ) ) . toBe ( true ) ;
723+ expect ( frames . some ( f => f . event === "response.completed" ) ) . toBe ( true ) ;
724+ expect ( frames . some ( f => ( f . data . response as Record < string , unknown > | undefined ) ?. incomplete_details ) ) . toBe ( false ) ;
725+ } ) ;
705726} ) ;
706727
707728describe ( "Responses bridge web_search_call native item" , ( ) => {
0 commit comments