Skip to content

Commit df57bd9

Browse files
authored
fix(bridge): keep wire heartbeats during adapter-only progress
Fixes #521. Rebased onto dev and verified with local targeted bridge test, typecheck, privacy scan, full pre-push gate, Sol independent audit, and hosted cross-platform CI.
1 parent 14ae6fe commit df57bd9

2 files changed

Lines changed: 41 additions & 9 deletions

File tree

src/bridge.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,17 +189,19 @@ export function bridgeToResponsesSSE(
189189
};
190190
// RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
191191
// eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
192-
// (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during
193-
// upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE".
194-
let activity = false;
192+
// (responses.rs `_ => Ok(None)`). Emit a parser-ignored `response.heartbeat` whenever the
193+
// *wire* has been silent, even if invisible adapter heartbeats are still flowing (web-search
194+
// buffering + raw-byte progress). Upstream activity only resets the stall watchdog.
195+
let upstreamActivity = false;
196+
let wireActivity = false;
195197
let beat: ReturnType<typeof setInterval> | undefined;
196198
let controller: ReadableStreamDefaultController<Uint8Array>;
197199
let emittedFrames = 0;
198200
let gated = false;
199201
let stepping = false;
200202
const emit = (name: string, data: Record<string, unknown>) => {
201203
if (closed) return;
202-
activity = true;
204+
wireActivity = true;
203205
try {
204206
controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
205207
emittedFrames++;
@@ -482,7 +484,9 @@ export function bridgeToResponsesSSE(
482484
if (next.done) { upstreamDone = true; break; }
483485
const event = next.value;
484486
let terminalEvent = false;
485-
activity = true;
487+
// Invisible adapter heartbeats (and buffered web-search progress) count as upstream
488+
// liveness only — they must not suppress wire keepalives that re-arm Codex idle timers.
489+
upstreamActivity = true;
486490
stallTicks = 0;
487491
reportFirstOutput(event);
488492
// Compaction turns emit ONLY the synthetic compaction item + response.completed. The
@@ -846,8 +850,10 @@ export function bridgeToResponsesSSE(
846850
gated = true;
847851
beat = setInterval(() => {
848852
if (closed || gated) return;
849-
if (activity) { activity = false; stallTicks = 0; return; }
850-
if (++stallTicks >= maxStallTicks) {
853+
if (upstreamActivity) {
854+
upstreamActivity = false;
855+
stallTicks = 0;
856+
} else if (++stallTicks >= maxStallTicks) {
851857
if (currentMsg) closeCurrentMessage();
852858
if (currentReasoning) closeCurrentReasoning();
853859
if (currentRawReasoning) closeCurrentRawReasoning();
@@ -871,6 +877,11 @@ export function bridgeToResponsesSSE(
871877
closed = true;
872878
return;
873879
}
880+
// Wire silence is independent of upstream adapter heartbeats.
881+
if (wireActivity) {
882+
wireActivity = false;
883+
return;
884+
}
874885
try {
875886
controller.enqueue(heartbeatFrame);
876887
emittedFrames++;

tests/bridge.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

707728
describe("Responses bridge web_search_call native item", () => {

0 commit comments

Comments
 (0)