Skip to content

fix(core): retry mid-stream transport failures as continuations - #7876

Closed
he-yufeng wants to merge 12 commits into
QwenLM:mainfrom
he-yufeng:fix/mid-stream-transport-continuation
Closed

fix(core): retry mid-stream transport failures as continuations#7876
he-yufeng wants to merge 12 commits into
QwenLM:mainfrom
he-yufeng:fix/mid-stream-transport-continuation

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

What this PR does

Fixes #7832. The stream transport retry used to fire only before the first chunk reached callers, so a socket close anywhere into a long thinking stream (UND_ERR_SOCKET minutes in) propagated and threw away everything already generated. With this change, when chunks have already flowed and the failed attempt left a text or thought partial, the retry runs as a continuation instead: the partial turn stays in history, a recovery user message shows the model its own suffix, and the model picks up where the stream died. One continuation per send; if it fails, the partial turn survives in history and on the transcript, and the error propagates as before.

Why it's needed

YOLO/headless generation of large outputs fails consistently for thinking models today: the whole think -> tool loop runs as a single SSE stream that outlives the gateway's idle window, the gateway closes the socket, and the session dies with [API Error: terminated]. Restarting from zero would also re-print the partial output in headless mode, which is why a plain after-chunk retry was never enabled. The continuation shape reuses the same machinery as the MAX_TOKENS output recovery (recovery message, isContinuation retry event, coalescing), so the UI keeps its buffer and the continuation appends seamlessly in both interactive and headless modes. FunctionCall partials still propagate immediately, since a user message cannot be injected between a functionCall and its response.

Reviewer Test Plan

How to verify

npx vitest run src/core/geminiChat.test.ts in packages/core: 242/242 pass. The new tests cover the three relevant shapes: mid-stream socket close with a text partial retries as a continuation (RETRY event with isContinuation: true, history coalesced to a single model turn holding partial + continuation, no synthetic user turn left behind); a functionCall partial still propagates with no retry attempt; a failed continuation keeps the partial turn and rethrows. The previously existing test that pinned "never retry after a chunk" was updated to the new contract, since that pinned behavior is exactly what #7832 reports as the bug.

Evidence (Before & After)

N/A (core retry semantics, no UI surface change; the TUI's existing isContinuation handling keeps the text buffer, verified by reading useGeminiStream.ts's Retry case).

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

makeMergedSessionUpdateEvent and mergeToolCallEvent rebuilt the event
envelope from scratch and dropped top-level promptId and
originatorClientId, even though the source chunks carried them. Live
subscribers see those fields on every chunk, but a client resuming from a
compacted replay lost them: prompt correlation broke and echo suppression
(event.originatorClientId === clientId) silently stopped applying to
merged events.

Track the latest promptId / originatorClientId on text and thought slots
(mirroring lastMeta / lastEnvelopeMeta) and copy them onto merged events;
folded tool calls take them from the incoming event with the existing
event as fallback, matching the id merge. Events whose sources carried no
attribution still emit none.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
The stream transport retry only fires before the first chunk reaches
callers. For thinking models the whole think -> tool loop runs as one
SSE stream that can outlive the gateway's idle window, so a socket
close mid-stream (UND_ERR_SOCKET after minutes of output) propagated
and discarded everything. In headless mode the partial text is already
printed and cannot be re-rendered either, which is the QwenLM#7832 report.

When chunks have already flowed and the failed attempt left a text or
thought partial (no functionCall, which cannot take an injected user
message), the retry now runs as a continuation instead: the partial
turn stays in history and on the transcript, a recovery user message
shows the model its own suffix, and the pair coalesces on success, the
same flow the MAX_TOKENS output recovery already uses. The UI keeps
its buffer through the isContinuation retry event, so the continuation
appends where the stream stopped. One continuation per send; if it
fails the partial turn survives and the error propagates, the same end
state as an unretryable mid-stream break.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: 0fc9c1eb18c90d435e3afd4f5b7e9cf4cc6d9ea3

Reason:

  • prompt_injection:print_secrets

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A second transport failure inside the new continuation path corrupts the persisted conversation history. The inline trace below shows the exact state transition and a bounded fix direction.

Comment thread packages/core/src/core/geminiChat.ts Outdated
self.coalesceRecoveryPairs(1);
lastError = null;
break;
} catch (continuationError) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] A continuation that itself drops after yielding text leaves consecutive model turns in history.

Trigger: the initial stream yields text A then a retryable socket error; the first continuation yields text B then the same error; the second continuation succeeds. processStreamResponse stashes B in pendingPartialTextParts. This catch removes only recoveryUserContent, then falls through to the outer transport-retry condition. That condition still uses the original retryable classification and streamYieldedChunk === true, sees the new B stash, and takes the remaining second retry.

The second retry pushes B as a new model turn after the already-persisted A. On success, coalesceRecoveryPairs(1) merges only B + C, leaving history as [... user, model(A), model(B+C)]. A later request therefore receives two adjacent model contents instead of the required alternating conversation, causing malformed context / provider rejection after exactly the recovery path this PR adds.

Do not fall through into another standalone continuation after continuationError; either bound continuation recovery to one attempt, or merge the new partial into the existing preceding model turn before retrying and coalesce the whole chain. Add a regression where both the initial stream and first continuation yield text before UND_ERR_SOCKET, then assert history has one combined model turn and no recovery user turn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful trace. I walked the control flow again on a6e7450, and the second continuation cannot fire there: the continuation attempt is wrapped in its own try/catch, so a transport error inside it is caught inline (that is where the recovery user message gets popped), lastError is replaced, and control moves on to the log-only "not taken" branch and then the catch tail, which ends with throw lastError. The continuation branch sits before the attempt in program order and is never re-entered within the same send, so the B stash never gets consumed by a second retry.

Pinned the exact chain you describe as a regression test in 064efac: attempt 1 yields "text A" then UND_ERR_SOCKET, continuation 1 yields "text B" then UND_ERR_SOCKET, and a third stream stands ready to succeed. The test asserts generateContentStream is called exactly twice, exactly one RETRY event (isContinuation) is emitted, the error propagates, and history is ['user', 'model'] holding only "text A ". No model(B), no adjacent model turns, no recovery user turn survives.

The end state after a failed continuation (partial A kept in history and on the transcript) is deliberate: it matches the existing "kept if break is unretryable" behavior of tool-call partials, and roles still alternate on the next send. Happy to adjust if you see a path I missed, but the [user, model(A), model(B+C)] shape is not reachable in the committed flow.

…ures

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
# Conflicts:
#	packages/acp-bridge/src/compactionEngine.ts
#	packages/core/src/core/geminiChat.test.ts
@yiliang114

Copy link
Copy Markdown
Collaborator

⚠️ Failed to process this request. Please re-mention the bot to retry.

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

…e replay

Merge main into fix/mid-stream-transport-continuation. Main's QwenLM#7938 split the transport retry gate into a from-zero replay for thinking-only output (!streamYieldedContentChunk); this PR adds a continuation retry for text/thought partials (streamYieldedChunk + pendingPartialTextParts). The replay block runs first and continues, so thinking-only failures replay from zero while content-phase failures continue. The not-taken log keeps main's skipped_after_content discriminator.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge summary: PR #7876 ← main

Root cause

Main's #7938 (commit d2ab8a559, "retry during the thinking-only phase") collided with this PR: both rework the same transport-failure retry gate in GeminiChat.sendMessageStream. #7938 replaced the !streamYieldedChunk gate with !streamYieldedContentChunk (set only for non-thought chunks) so thinking-only streams replay from zero; this PR adds a second path (continuation) for text/thought partials. The other main commit on these files (#7963) was unrelated adjacency and merged cleanly.

Textual or semantic

Semantic. In geminiChat.ts only the comment above the "retry not taken" log textually conflicted; the code auto-merged but shares one control flow. Resolved ladder:

// 1. #7938: thinking-only -> replay from zero
if (isRetryableStreamTransportError && !streamYieldedContentChunk && budget) { ; continue; }
// 2. this PR: text/thought partial -> continuation
if (isRetryableStreamTransportError && streamYieldedChunk && pendingPartialTextParts !== null && budget) {  }
// 3. otherwise propagate (keeps main's discriminator)
retryDecision: streamYieldedContentChunk ? 'skipped_after_content' : 'exhausted'

In the test file both sides rewrote the same pre-existing test. I kept the PR's stops continuation-retrying after a single failed continuation (body already auto-merged) and dropped main's rename does not retry … after yielding a content chunk, whose assertions (1 call, 0 RETRY) contradict the merged behavior — a text chunk now triggers a continuation retry. Main's two thinking-phase tests were kept.

What is load-bearing

  • Ordering: the from-zero block (1) must stay before the continuation block (2). A thinking-only failure satisfies both gates; only block 1's continue makes it replay from zero instead of continuing.
  • The two partial stashes are mutually exclusive: functionCall partial → pendingPartialAssistantTurnIndex (popped on replay); text/thought partial → pendingPartialTextParts (drives continuation). A continuation is never valid across a functionCall.

What I could not verify (needs follow-up)

No build/tests run here. Main's auto-merged, NON-conflicted test does not retry when visible content followed the thinking chunks (prompt prompt-transport-no-retry-after-thinking-then-content) will fail under the merged behavior. It yields thought + visible text then asserts toHaveBeenCalledTimes(1) and 0 RETRY events, but the continuation retry now fires there (2 calls, 1 isContinuation RETRY). Left untouched per scope — update it to expect a continuation retry, or drop it.

中文说明

根因: main 的 #7938d2ab8a559)与本 PR 都改写 sendMessageStream 同一个传输重试门。#7938!streamYieldedChunk 换成 !streamYieldedContentChunk(仅非 thought chunk 置位)使纯思考流从零重放;本 PR 新增第二条 continuation 路径。#7963 仅相邻无关,已干净合入。

语义冲突: geminiChat.ts 只有"retry not taken"注释文本冲突,代码自动合并但共用控制流,顺序见上。测试文件双方改写同一既有测试:保留 PR 的 stops continuation-retrying…,删除 main 改名版(其"1 次调用、0 RETRY"与合并行为矛盾),保留 main 两个 thinking 测试。

关键依赖: 从零重放块必须排在 continuation 块之前(纯思考失败同时满足两个门,靠前者 continue 才从零重放);两个片段暂存互斥(functionCall→pendingPartialAssistantTurnIndex,文本/思考→pendingPartialTextParts),continuation 不跨 functionCall。

需后续: 未跑构建/测试。main 未冲突测试 does not retry when visible content followed the thinking chunks 合并后会失败(现会触发 continuation:2 次调用、1 个 isContinuation RETRY)。按范围未改动,需更新或删除。

wenshao and others added 4 commits July 31, 2026 22:20
After the merge with QwenLM#7938, a transport failure after thinking chunks
plus visible content takes the continuation path (blocked from replaying
from zero), so the old no-retry assertion contradicts the merged
behavior. Assert the continuation instead: one retry marked
isContinuation and the coalesced visible text preserved.
@he-yufeng
he-yufeng requested a review from doudouOUC as a code owner August 3, 2026 08:58
@wenshao

wenshao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

PR QwenLM#7896 (d7c0d4c) landed on main with a superset mechanism for the same mid-stream transport-cut recovery this PR implements. Resolve the semantic conflict in favor of main:

- geminiChat.ts becomes identical to main: drop the pendingPartialTextParts stash and the history-based continuation block. Keeping both left a reachable duplicate continuation path that resumed from only the last attempt's partial while the accumulated prefix stayed unstitched.

- geminiChat.test.ts keeps main's QwenLM#7832 suite and re-adds the PR tests that pass under the QwenLM#7896 mechanism; drop the two pins that contradict it (one-continuation-per-send, partial-turn-kept-on-failed-continuation). Main allows up to maxContinuationRetries chained continuations and deliberately persists no text-only partial on failure.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Root cause

main merged #7896 (d7c0d4ca7, "fix(core): resume long streams cut by a socket-level close"), which implements the same feature as this PR — recovering mid-stream transport cuts by continuation — with a different mechanism. Both branches added continuation logic beside the same transport catch block in geminiChat.ts; git auto-merged almost everything textually, leaving both mechanisms live; the one textual conflict was just a comment.

Semantic, not textual

The auto-merged tree kept main's local-buffer continuation (transportContinuationText, own maxContinuationRetries: 3 budget, synthetic turns via buildAttemptContents(), stitched into history on success by prependTextToLastModelTurn) and the PR's stash-based one (pendingPartialTextParts, partial turn pushed to history + JSONL up front, nested API call, coalesceRecoveryPairs). Main's gate fires first and continues, but the PR's block stayed reachable once main's budget exhausted — resuming from only the last attempt's partial while the earlier accumulated prefix never got stitched into history. Keeping both was broken, so the resolution defers to main: geminiChat.ts is now byte-identical to origin/main — all five PR source additions (field, continuation block, stash branch, reset line, warn comment) belonged to the superseded mechanism.

What is load-bearing

  • geminiChat.ts must stay identical to main. Reintroducing pendingPartialTextParts reopens the duplicate continuation path.
  • Main's #7832 test suite pins chained continuations (initial attempt + up to 3 continuations = 4 calls on exhaustion) and that text-only partials are never persisted on failure. Two PR tests pinned the opposite (one continuation per send; partial kept after failed continuation) and were dropped. Three PR tests that hold under main's mechanism were re-added verbatim (merged-history-turn assertion, isContinuation RETRY flag, no continuation after a functionCall cut).

Could not verify

No build/tests run (this command resolves conflicts only). The three re-added tests were hand-traced against main's mechanism and should pass; CI is the oracle. Note for reviewers: after this merge the PR's net contribution over main is tests only (+244 test lines plus the auto-merged acp-bridge test) — the runtime fix is now provided entirely by #7896; maintainers may prefer closing it.

中文说明

根因:main 合入了 #7896(commit d7c0d4c),它实现了与本 PR 相同的功能——流中断后以"续写"方式恢复,但机制不同。两边在 geminiChat.ts 同一个 transport catch 块旁添加续写逻辑,git 几乎全部按文本自动合并,导致两套机制并存;唯一的文本冲突只是兜底 warn 上方的注释。

语义冲突:自动合并同时保留了 main 的局部缓冲续写(独立预算 maxContinuationRetries: 3、合成请求轮次、成功后用 prependTextToLastModelTurn 拼接 history)和本 PR 的暂存式续写(pendingPartialTextParts、部分轮次预先写入 history/JSONL、嵌套 API 调用)。main 分支先触发;但本 PR 分支在 main 预算耗尽后仍可达,且只用最后一次尝试的片段续写,此前累积的前缀永远不会并入 history。两套并存必然出错,故采用已合入的超集 main 版本:geminiChat.tsorigin/main 完全一致。

关键点:geminiChat.ts 必须保持与 main 一致,重新引入 pendingPartialTextParts 会复活重复路径。main 的 #7832 套件钉住链式续写(最多 3 次,共 4 次调用)且失败时不持久化纯文本片段;本 PR 与之矛盾的两个测试已删除,其余三个在 main 机制下成立的测试原样加回。

未能验证:本流程不跑构建/测试;三个加回的测试经手工推演应能通过,以 CI 为准。合并后本 PR 相对 main 只剩测试增量,运行时修复完全由 #7896 提供,维护者可考虑直接关闭本 PR。

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Closing: #7896 landed the same feature (mid-stream transport continuation) while this was in review, and after the conflict merge the runtime diff here was reduced to zero. I also checked the remaining test-only delta against main before closing: the isContinuation retry flag and the merged single model turn are pinned by the #7832 suite, and the functionCall cut case has its own test ('does not continue a cut that delivered a functionCall'). Nothing left that main does not already have. Thanks for the reviews along the way.

@he-yufeng he-yufeng closed this Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

YOLO mode: mid-stream socket close is not retried, making large code generation impossible

6 participants