Summary
The built-in empty-turn retry loop never runs for direct openai→openai turns, and the buffered-turn judge counts draft-only text as content. Together these let a degenerate turn through to the client instead of being retried on another connection.
Reported separately from #17278 per your request.
Environment
- OmniRoute Desktop 3.8.52, Windows 11
- Upstream: local OpenAI-compatible bridges (JetHub pools on
127.0.0.1:64646, WorkBuddy bridge on127.0.0.1:47825) - Affected provider in practice: GLM-5.3 over a direct openai→openai connection
Problem 1 — the gate excludes direct (non-translated) turns
runEmptyTurnRetryLoop is called behind two conditions:
let e = false;
try { e = isFeatureFlagEnabled("FLUSH_EMPTY_RETRY_ENABLED") } catch { e = false }
let o = targetFormat === FORMATS.OPENAI_RESPONSES || needsTranslation(targetFormat, clientResponseFormat);
if (e && o) { let r = await runEmptyTurnRetryLoop({ … }) }FLUSH_EMPTY_RETRY_ENABLEDdefaults tofalse.- Even with the flag on,
ois false for openai→openai, so direct providers (GLM, WorkBuddy, and everyopenai-compatible-chat-*node) never reach the retry loop.
So the loop only helps translated paths, which is the opposite of where the degenerate turns actually come from in my traffic.
Problem 2 — the judge treats draft-only text as content
judgeBufferedTurn decides emptiness with:
!k.has(i.finishReason) && !i.toolCallsPresent && !(i.contentText.length > 0) && ( … )contentText.length > 0 is the only text test. A turn whose entire output is a compression-draft skeleton:
<summary>…</summary>
<analysis>…</analysis>has non-zero contentText, so it is classified as a normal turn and forwarded downstream. The same is true of the inline early-pass probe (isUsefulSummary), which sees the same non-empty text and passes it before the judge ever runs.
Observed symptom
With GLM-5.3 on long conversations, the client receives a turn that carries only draft markup. The downstream consumer strips the tags, is left with an empty turn, and kills the stream. Log line from the client side:
[ACP] stream error: the turn degenerated again after the continuation nudgeThe gateway's own recovery machinery (runEmptyTurnRetryLoop, bounded at STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX = 4) is never consulted, so a recoverable turn becomes a dead session.
Requests
- Let the flag cover direct paths. The translate-only guard looks like it predates direct-provider support. If the retry loop is safe for translated turns it should be safe for openai→openai; gating on
ealone would let operators opt in for all streaming paths. - Make the judge look at content, not just length. A
contentTextthat is only<summary>/<analysis>blocks (no text outside the tags) should classify as an empty turn so the existing retry path can take over. Same for the early-pass probe, otherwise the probe returns before the judge is reached.
Local workaround I am running (for reference)
Three text edits in the server bundle, all asserted to match exactly once:
- P1 judge: treat draft-only
contentTextas empty - P2 probe: draft-only text must not early-pass
- P3 gate:
if (e && o)→if (e), withFLUSH_EMPTY_RETRY_ENABLED=trueset by the launcher
P1/P2/P3 are applied to 3 identical chunk copies. This is a bundle hot-patch that dies on the next update — hence the issue.
Note
I have not been able to confirm whether the retry loop is correct for direct paths (e.g. whether credential rotation and leased/pinned semantics behave the same when needsTranslation is false). If there is a reason the guard exists, say so and I will drop request 1 and keep only the judge fix.
Activity
0No activity yet.