Skip to content

fix(core): record the delivered prefix when a transport cut is continued - #8624

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
harjothkhara:oss-find/qwen-code-2026-08-06
Aug 7, 2026
Merged

fix(core): record the delivered prefix when a transport cut is continued#8624
wenshao merged 5 commits into
QwenLM:mainfrom
harjothkhara:oss-find/qwen-code-2026-08-06

Conversation

@harjothkhara

Copy link
Copy Markdown
Contributor

Refs: #8094

The bug

After a socket cut mid-response, the transport-continuation path asks the model to resume from the text the user already saw. On success, prependTextToLastModelTurn merges that delivered prefix back into the trailing model turn — but it writes this.history and nothing else:

https://github.com/QwenLM/qwen-code/blob/main/packages/core/src/core/geminiChat.ts#L2731-L2733

So /compress and every later turn read a coherent answer, while the JSONL transcript that --resume / --continue reads keeps only the resumed remainder. The recovered turn starts mid-sentence on resume.

Not a regression: before #7896 the cut turn recorded no assistant record at all.

The fix

Merge the prefix into the assistant record as it is built, reusing the same overlap dedup prependTextToLastModelTurn already uses. One turn in, one matching turn on disk.

Why at the record build and not next to the history merge. The record is appended from inside processStreamResponse, before the outer send loop regains control at :2731, and appendRecord is append-only. A second record written "alongside prependTextToLastModelTurn" would land after the remainder, and resume maps records to Content[] in file order — so the transcript would read [remainder][prefix], halves reversed. That rules out the record-a-second-turn shape.

Why success-only. On streamError !== null the record must keep matching the remainder-only partial that survives in history (the pendingPartialAssistantRecord path), and a fresh-restart retry discards the prefix from history via resetTransportContinuation. Merging unconditionally would put the delivered text in the transcript but not in history — the same desync, pointing the other way.

Why a parameter, not a field. The prefix rides as a per-attempt argument captured by value, so it dies with the attempt. There is no stash that can dangle into a later turn — which matters, because NO_TOOL_RESULT_PROGRESS_MAX_TOKENS breaks out of the loop without reaching either the merge or the reset, and an instance-field stash flushed in the generator's finally would write a spurious prefix record there.

resetTransportContinuation needs no change: it already clears transportContinuationPrefix, so the next attempt is invoked with undefined.

Proof

packages/core, Node 22.22.3, fully mocked (fake timers, no network).

RED — new tests against unmodified geminiChat.ts:

FAIL > transport stream continuation (#7832) > records the delivered prefix with the resumed remainder in one turn
  expected 'second half' to be 'first half second half'

FAIL > transport stream continuation (#7832) > dedupes replayed overlap in the recorded turn too
  expected 'jumps over the lazy dog.' to be 'The quick brown fox jumps over the lazy dog.'

Tests  2 failed | 16 passed | 285 skipped (303)

The two guard tests (discard-on-reset, and the tool-call-cut case) pass on unmodified source — they are tripwires, not the red.

GREEN

Tests  19 passed | 285 skipped (304)     # transport stream continuation block

Mutation checks — each mutant killed by exactly the test that should catch it:

Mutant Result
drop streamError === null && (merge on failure too) FAIL keeps the record remainder-only when the continuation itself is cut after a tool call
prefix + contentText (naive concat, no overlap dedup) FAIL dedupes replayed overlap in the recorded turn too
never thread the prefix (pass undefined) FAIL both merge tests

Blast radius, by symbol not directory. processStreamResponse has one production caller plus a direct call in goal-turn-integration.test.ts; makeApiCallAndProcessStream has three call sites (:2696 changed, :3225 and :3899 pass nothing → undefined → byte-identical behavior). Every file matching those symbols or recordAssistantTurn:

✓ src/core/geminiChat.test.ts                       (304 tests)
✓ src/core/client.test.ts                           (316 tests)
✓ src/services/chatRecordingService.test.ts          (69 tests)
✓ src/services/chatRecordingService.autoTitle.test.ts (20 tests)
✓ src/core/goal-turn-integration.test.ts              (3 tests)

Full suite, against restored upstream sources vs this branch:

upstream main   19235 passed | 11 skipped | 0 failed
this branch     19239 passed | 11 skipped | 0 failed    (+4, the new tests)

tsc --noEmit exit 0 on both.

Demo

N/A — no user-facing change in the live session. The change is to the durable transcript, and the observable difference is the recorded turn text, asserted directly in the tests above.

Limits

  • Proof drives the real sendMessageStreamprocessStreamResponse path with a stubbed content generator; it does not run against a gateway that really destroys the TCP socket. The original measurements in Transport-continuation recovery leaves the resumed transcript starting mid-sentence #8094 are @wenshao's from that setup.
  • Pre-existing and left alone: when a continuation fails unretryably, the delivered text is in neither history nor the transcript. Both layers agree, so it is not this bug, but it is still text the user saw and no layer keeps.

AI-assisted: investigated, implemented and proven with Claude Opus 5.

After a socket cut mid-response, the continuation attempt resumes from
the text the user already saw. `prependTextToLastModelTurn` merges that
prefix back into the trailing model turn, but it writes `this.history`
and nothing else. The JSONL transcript keeps only the resumed remainder,
so `--resume` and `--continue` rehydrate a turn that starts mid-sentence
while the live session shows a coherent answer.

Merge the prefix into the assistant record as it is built, reusing the
same overlap dedup `prependTextToLastModelTurn` uses, so one turn goes in
and one matching turn lands on disk.

The merge belongs at the record build, not next to the history merge:
the record is appended from inside `processStreamResponse`, before the
outer send loop regains control, and `appendRecord` is append-only — a
second record written afterwards would sit behind the remainder and
resume would read the halves out of order.

It is gated on success. When the attempt fails, the record has to keep
matching the remainder-only partial that survives in history, and a
fresh-restart retry discards the prefix from history via
`resetTransportContinuation`. The prefix rides as a per-attempt argument
rather than instance state, so no stash can dangle into a later turn.

Refs: QwenLM#8094

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@harjothkhara
harjothkhara marked this pull request as ready for review August 6, 2026 15:50
…timers

The tool-call-cut test drained its stream with
`collectStreamWithFakeTimers`, which returns the collecting promise only
after advancing timers. That send rejects during the advance, so the
rejection sat unhandled for a tick — vitest caught it as an unhandled
error and exited 1 with every test still passing.

Attach the assertion first, then advance, matching the shape
`expectStreamExhaustion` in this file already uses for the same reason.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

@wenshao wenshao 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.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

— qwen3.8-max via Qwen Code /review (v0.21.6)

Comment thread packages/core/src/core/geminiChat.ts Outdated
// `pendingPartialAssistantRecord` path below) — the prefix belongs to an
// attempt that did not survive, and a fresh-restart retry discards it
// from history via `resetTransportContinuation`.
const recordedContentText =

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] R1-1: The record-side prefix merge computes getRecoveryContinuationSuffix against contentText — the text parts joined and .trim()-ed (~line 4674) — while the history-side merge in prependTextToLastModelTurn (~line 4997) dedupes against the raw, untrimmed first plain-text part. The two layers this PR commits to holding identical compute the merged turn text from different operands whenever the continuation remainder has leading/trailing whitespace.

Failure scenario: Transport cut lands before a space byte (a normal token boundary); prefix "The result is", continuation remainder " 42.". History merges the raw part → "The result is 42."; the record trims the merge input → "The result is42." (fused words). Probe-observed on the unmodified PR, including a dedup-divergence variant: prefix "The grand total", remainder " total sum is 9." → record "The grand totaltotal sum is 9." (6-byte overlap " total" is significant only untrimmed) vs clean history. --resume rehydrates the corrupted turn; the four new tests put boundary spaces on the prefix side only, so none reaches this input.

Suggested fix: Dedup the record merge against the same untrimmed input the history merge uses (the raw join of consolidatedHistoryParts text parts), or route both merges through one shared helper so they cannot diverge; add a regression test whose continuation chunk starts with whitespace and asserts record == history byte-for-byte.

— qwen3.8-max via Qwen Code /review (v0.21.6)

Comment thread packages/core/src/core/geminiChat.ts Outdated
// `pendingPartialAssistantRecord` path below) — the prefix belongs to an
// attempt that did not survive, and a fresh-restart retry discards it
// from history via `resetTransportContinuation`.
const recordedContentText =

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] R2-2: The merged record is written inside processStreamResponse before the outer send loop's prependTextToLastModelTurn runs. On a send that is both a tool-result continuation (deferredFinishReason synthetic chunk yielded after record-write + history-push, ~4952) and a successful transport continuation, there is a one-chunk suspension window between record write and history merge. Abandoning iteration at that chunk leaves the merged record persisted while history keeps the remainder-only turn. Pre-diff the record in that window was remainder-only and matched history — the diff introduces the window (verified against the base tree).

Failure scenario: Probe-observed on the unmodified PR: for-await broken at the CHUNK carrying the synthetic deferred finishReason — the exact model of Turn.run's abort-return (turn.ts:497-501 checks signal.aborted at the top of every iteration; Esc drives it via useGeminiStream.ts cancelOngoingRequest) — yields record "Analysis: the file contains the bug." vs history "contains the bug.". The divergence is persistent once hit (append-only JSONL, nothing reconciles) and defeats the PR's stated invariant in the opposite direction; trigger frequency (tool-result continuation ∧ successful transport continuation ∧ Esc inside the final-event window) is situational, but the mechanism is deterministic and high confidence.

Suggested fix: Compute the merged text where both layers are written from one merge that cannot be skipped independently — e.g. perform the history merge inside processStreamResponse right after history.push and before the synthetic-chunk yield (validated by probe: flips the abandon arm green with the control still green; the outer merge re-runs idempotently), or defer the record append until after the history merge like pendingPartialAssistantRecord.

— qwen3.8-max via Qwen Code /review (v0.21.6)

Comment thread packages/core/src/core/geminiChat.ts Outdated
// `pendingPartialAssistantRecord` path below) — the prefix belongs to an
// attempt that did not survive, and a fresh-restart retry discards it
// from history via `resetTransportContinuation`.
const recordedContentText =

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.

[Suggestion] R1-2: The merge composition prefix + getRecoveryContinuationSuffix(prefix, remainder) is hand-written at three sites: foldTransportAttemptText (~2660), prependTextToLastModelTurn (~4997), and the new recordedContentText merge (~4827). Nothing forces the twin expressions to keep agreeing. The file already treats this drift risk as real one block down: the willPersistToHistory gates share a single binding (~4898-4904) specifically so the JSONL recording cannot silently desync from in-memory history.

Failure scenario: Any future change to the merge semantics (dedup rule, whitespace guard, floor adjustment) applied on one side only silently reintroduces the transcript/history desync this PR exists to remove — the synchronized-edit invariant is encoded nowhere structurally. This is no longer hypothetical: R1-1's probe shows the twin expressions already diverge on the same turn.

Suggested fix: Extract one shared helper, e.g. mergeDeliveredPrefix(prefix, remainder) = prefix + getRecoveryContinuationSuffix(prefix, remainder) (and, per R1-1, over the same untrimmed operand), called from both prependTextToLastModelTurn and the record path — mirroring the shared-binding pattern already used for the persistence gates.

— qwen3.8-max via Qwen Code /review (v0.21.6)

harjothkhara and others added 3 commits August 6, 2026 16:29
Addresses review findings R1-1, R2-2 and R1-2 on QwenLM#8624.

The first version merged the prefix into the JSONL record only, at the
point the record was built, and left history to the outer send loop's
`prependTextToLastModelTurn`. Two expressions, two write times — and both
diverged.

R1-1: the record deduped against `contentText`, which is trimmed, while
history deduped against the raw part. A cut landing on a token boundary
recorded "The result is" + " 42." as "The result is42.", and a 6-byte
overlap that is significant only untrimmed recorded "The grand
totaltotal sum is 9.".

R2-2: the record was appended before the history push, and a tool-result
continuation yields a deferred finishReason chunk after it. A consumer
abandoning iteration there — an abort inside `Turn.run` — left a merged
record against a remainder-only history, permanently, since the JSONL is
append-only.

Fold the prefix into `consolidatedHistoryParts` instead, once, after
stream validation and before either write. The record and the history
push are then built from the same parts, so they cannot disagree about
whitespace, dedup, or timing. The outer merge is gone, along with
`prependTextToLastModelTurn`, and the merge itself is now one shared
helper (R1-2) rather than a hand-written expression per site.

Not re-run in the outer loop on purpose: the dedup helper only strips a
replayed prefix that clears its significance floor, so a short prefix
would survive a second pass and be doubled.

Refs: QwenLM#8094

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The new helper was inserted between that JSDoc block and the function it
documents, so the block silently reattached to the wrong function.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@harjothkhara

Copy link
Copy Markdown
Contributor Author

Thanks — R1-1 and R2-2 were both real, and both reproduced. They turned out to be the same weakness seen twice, so I've reworked the fix rather than patching each one.

Root cause of both

The original diff merged the prefix into the JSONL record only, at the point the record was built, and left history to the outer send loop's prependTextToLastModelTurn. Two expressions, evaluated at two different times. R1-1 is those expressions disagreeing about their operand; R2-2 is them disagreeing about when they run. Fixing either one alone leaves the other shape available.

So the prefix is now folded into consolidatedHistoryParts once, before either durable write. The record and the history.push are then built from the same parts and cannot disagree — about whitespace, dedup, or timing.

R1-1 — confirmed

contentText is .join('').trim(); the pushed parts are raw. Both your examples reproduce against the previous head:

expected 'The result is42.'               to be 'The result is 42.'
expected 'The grand totaltotal sum is 9.' to be 'The grand total sum is 9.'

The second is the worse half: " total" clears the 6-byte floor only while untrimmed, so trimming the operand didn't just shift whitespace, it silently lost the dedup.

R2-2 — confirmed

The record was appended inside processStreamResponse, history.push came after it, and the deferredFinishReason chunk is yielded after that — a suspension point. Abandoning iteration there is exactly Turn.run's abort-return. Reproduced: record 'Analysis: the file contains the bug.' against history 'contains the bug.', permanent because the JSONL is append-only. You're right that the diff introduced this; pre-diff both layers were remainder-only and agreed.

One correction on the suggested fix. Re-running the outer merge idempotently isn't safe. getRecoveryContinuationSuffix only strips a replayed prefix that clears isSignificantRecoveryOverlap's 6-byte prose / 4-byte structural floor, so a prefix shorter than that survives the second pass and gets doubled. The merge is therefore done once and the outer call removed rather than made re-entrant; that reasoning is in the code comment so nobody restores it later.

R1-2 — taken

Extracted mergeDeliveredPrefix(deliveredText, continuationText). With the merge now happening in one place, prependTextToLastModelTurn had no callers and is deleted, so the twin expressions are gone rather than kept in sync by hand.

What changed

packages/core/src/core/geminiChat.ts — merge folded into the parts in processStreamResponse (after the stream-validation throws, so an empty continuation still fails on its own merits rather than being masked by the prefix); outer merge and prependTextToLastModelTurn removed; shared helper added.

Three new tests, each red against the previous head:

  • merges a whitespace-leading remainder identically in both layers
  • keeps a whitespace-boundary overlap dedup consistent across layers
  • agrees across layers when the consumer aborts at the deferred finish chunk

These assert record === history, not just that the record looks merged. That gap is what let both findings through: the original four tests all checked the record and never compared the two layers, which is precisely the invariant the PR claims.

packages/core in CI: 19279 passed, 0 failed. Locally the same suite is +7 against the identical base without this diff (19276 vs 19269) — the delta is exactly the new tests.

The branch is also merged up to current main; the earlier red was Missing script: "check:voice-guard-sync", a step main added after this branch's base. CI is green on the merged head.

Not addressed

On an unretryable failure after a continuation, the delivered text is in neither history nor the transcript. Both layers agree, so it isn't this bug — it's merge-base behavior and I've left it alone rather than widen the diff.

@harjothkhara
harjothkhara requested a review from wenshao August 7, 2026 00:57

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +4884 to +4888
contentText = consolidatedHistoryParts
.filter((part) => part.text)
.map((part) => part.text)
.join('')
.trim();

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.

[Suggestion] R3-1: This adds a third copy of the contentText derivation expression inside processStreamResponse — identical copies already exist at ~line 4700 (initial binding) and ~line 4750 (XML-tool-call recompute). The comment block above this merge invokes exactly this drift hazard as load-bearing ("Computing the record's text and the history's text from separate expressions lets them drift"), while the diff adds a third copy of one of the drifting expressions.

Failure scenario: any future change to the derivation rule (switching the filter predicate, changing the join) applied at one or two of the three sites leaves the JSONL record's text diverging from the pushed parts — the record/history desync class this PR exists to remove.

Suggested fix — extract one helper used at all three sites:

const deriveContentText = (parts: Part[]) =>
  parts.filter((part) => part.text).map((part) => part.text).join('').trim();

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +846 to +849
function mergeDeliveredPrefix(
deliveredText: string,
continuationText: string,
): string {

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.

[Suggestion] R3-2: This helper's doc claims it is "The single definition of 'merged turn text' for the transport-continuation path", but foldTransportAttemptText (~2681-2687) still inlines the byte-identical merge arithmetic: transportContinuationText += getRecoveryContinuationSuffix(transportContinuationText, transportAttemptText) is exactly transportContinuationText = mergeDeliveredPrefix(transportContinuationText, transportAttemptText). The buffer that fold builds is assigned to transportContinuationPrefix at ~2956 — it is the very prefix this helper later merges into the persisted turn.

Failure scenario: if the merge/dedup rule ever changes (normalisation, the significance floor in getRecoveryContinuationSuffix), the accumulated prefix and the persisted merge diverge — doubled or dropped text on continuation.

Suggested fix in foldTransportAttemptText:

transportContinuationText = mergeDeliveredPrefix(
  transportContinuationText,
  transportAttemptText,
);

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +4866 to +4868
const textIndex = consolidatedHistoryParts.findIndex(isPlainTextPart);
if (textIndex < 0) {
// Continuation returned no text of its own (e.g. only a functionCall).

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.

[Suggestion] R3-3: The textIndex < 0 (unshift) branch of this merge has no test at the JSONL-record layer. All seven new recorder-based tests feed the continuation a text remainder, and the one pre-existing test reaching this branch ('inserts the delivered text when the continuation has no text part') uses a plain chat with no recorder and asserts only history. In this branch the record's text comes solely from the contentText recompute above the record build.

Failure scenario (probe-verified): moving that recompute inside the else — so it is skipped exactly when textIndex < 0 — leaves all 307 tests in this file green while the record silently drops the delivered prefix (the record gate still passes via thoughtContentPart/hasToolCall) even though history keeps it: the exact record/history desync this PR removes, surviving in the sibling branch. A probe test asserting the recorded prefix flips the mutant: expected undefined to be 'delivered prefix'.

Suggested fix: add one chatWithRecorder test whose continuation completes with only a functionCall (and/or only a thought part), asserting the recorded message's text part equals the delivered prefix and matches history.

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +4865 to +4866
if (streamError === null && transportContinuationPrefix) {
const textIndex = consolidatedHistoryParts.findIndex(isPlainTextPart);

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.

[Suggestion] R3-4: The merge — including its heuristic dedup decision — runs silently: this block contains no debugLogger call, while the file codifies the investigator-anchor standard for comparable recovery machinery (the [PARTIAL_PUSH] comment: without the anchor line, an investigator "sees the downstream symptom but has no anchor for when/why"). The continuation path logs 'Transport stream continuation scheduled' (with deliveredChars) when the continuation is scheduled, but nothing records that the merge ran, which branch (inserted vs deduped) it took, or how many chars the dedup consumed.

Failure scenario: on a cut-prone gateway the dedup decides wrong (the contained-prefix path strips a legitimately repeated block, or a sub-floor replayed tail gets doubled); days later a user reports corrupted --resume output. The investigator has no log line proving the merge ran or which branch it took, and the JSONL is append-only, so the live state is gone.

Suggested fix — one trace line after the merge:

debugLogger.info(
  '[TRANSPORT_CONTINUATION] Merged delivered prefix into continuation turn',
  {
    prefixChars: transportContinuationPrefix.length,
    mergedChars: contentText.length,
    mode: textIndex < 0 ? 'inserted' : 'deduped',
  },
);

— qwen3.8-max via Qwen Code /review (v0.21.7)

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ inconclusive — completed without a usable structured verdict - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

中文 — 判定:⚠️ 无法判定 · 已完成但无可用的结构化判定

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

No report.md was found in the run artifacts, so the report section is omitted — see the workflow run output.

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: present in substance, not in shape — the body uses its own headings (The bug / The fix / Proof / Demo / Limits) but covers everything the template asks for: what and why with a pinned root cause, a reviewer test plan with RED/GREEN and mutation evidence, an explicit N/A for before/after (no live-session surface), risk & scope, and the linked issue. Not worth a round-trip; only noting the bilingual <details> section is absent.

Problem: observed, not theoretical. #8094 is open with real-gateway measurements (socket destroyed mid-SSE stream), the root cause is pinned to the exact spot where prependTextToLastModelTurn writes in-memory history but nothing writes the JSONL record, and the desync is visible on --resume.

Direction: aligned — durable-transcript fidelity on --resume / --continue is core session-management reliability; this makes the durable layer agree with the in-memory layer that #7896 already fixed.

Size: core path hit (geminiChat.ts) — 158 production lines (+106/−52), 372 test lines, 0 generated/schema. Well under the 500-line escalation bar.

Approach: scope feels right. One merge point inside processStreamResponse before either durable write, a single shared mergeDeliveredPrefix helper, success-only guard, per-attempt parameter instead of instance state, and the now-dead prependTextToLastModelTurn fully removed. This is exactly the shape the round-1 review's Criticals demanded, and the diff carries nothing beyond it.

Risk: high-risk path matched (geminiChat — the strongest revert-correlation signal in this repo), so this got full-depth review and CI evidence is required before approval — both in the comment below.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:实质内容齐全,只是没用模板标题——正文用自己的标题(The bug / The fix / Proof / Demo / Limits)覆盖了模板要求的全部内容:改动与动机(含定位到行的根因)、带 RED/GREEN 与变异测试证据的验证计划、明确的 Before/After N/A(无会话内可见面)、风险与范围、关联 issue。不值得为此打回;仅提示缺少中文 <details> 部分。

问题:已观测到的 bug,非理论问题。#8094 仍然 open,有真实网关(SSE 流中途断开 socket)的测量数据,根因定位到 prependTextToLastModelTurn 只写内存 history、不写 JSONL 记录的具体位置,--resume 时差异可见。

方向:对齐——--resume / --continue 的持久转录保真度是会话管理的核心可靠性;此 PR 让持久层与 #7896 已修复的内存层保持一致。

规模:触及核心路径(geminiChat.ts)——158 行生产代码(+106/−52),372 行测试,0 行生成/schema。远低于 500 行升级线。

方案:范围合理。合并点收敛到 processStreamResponse 内两处持久写入之前,共用一个 mergeDeliveredPrefix helper,仅成功路径生效,prefix 按次传参而非实例状态,已无用的 prependTextToLastModelTurn 完整删除。这正是 round-1 两个 Critical 要求的形态,diff 中没有多余改动。

风险:命中高风险路径(geminiChat——本仓库回滚相关性最强的信号),因此做了全深度审查,且批准前要求 CI 证据——见下条评论。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at d4810eab8b0d7f41afa0ff045acdfb86f5c4072a · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Independent baseline first: from the title and issue alone, I'd have merged the delivered prefix into the response parts inside processStreamResponse before either durable write, behind one shared dedup helper, success-only, with the redundant outer merge deleted — which is exactly what this PR does after the round-1 fixes. No simpler path was missed.

How the two round-1 Criticals are settled in the current head:

  • R1-1 (trimmed vs raw operand): the merge now runs on the raw part — mergeDeliveredPrefix(prefix, remainderPart.text) — and contentText is recomputed from the merged parts, so the record and history both derive from one merge instead of two twin expressions. The word-fusing ("The result is" + " 42.") and lost-overlap (" total") cases can no longer diverge between layers, and two new tests reproduce the exact probe scenarios.
  • R2-2 (abort window): both durable writes now read already-merged parts; there is no suspension point between "record merged" and "history merged", so abandoning iteration at the deferred-finishReason chunk (the Esc/abort model in Turn.run) can no longer strand a merged record against remainder-only history. The abort-at-deferred-chunk test pins it.

I verified the load-bearing claims against the base tree, not just the diff: the removed prependTextToLastModelTurn had exactly one caller; the other two makeApiCallAndProcessStream call sites pass nothing → byte-identical behavior; the merge sits after the stream-validation throws, so an empty continuation still fails validation on its own merits; thought parts are prepended at push time, so the unshift lands after them; resetTransportContinuation already clears the prefix (no change needed); the error paths (pendingPartialAssistantRecord, partial history push) stay remainder-only by construction via the success-only guard; the NO_TOOL_RESULT_PROGRESS break exits before the merge, and the per-attempt value capture means nothing can dangle into a later turn.

Non-blocking nits carrying over from round 3 (none gate this): foldTransportAttemptText could call mergeDeliveredPrefix instead of inlining the byte-identical arithmetic; the no-text-continuation (unshift) branch has no recorder-layer test yet — moving the contentText recompute into the else would survive the current suite; and one debugLogger trace line on the merge branch would match the [PARTIAL_PUSH] investigator-anchor standard already in this file.

Testing — this PR's own CI, quoted via the API (I do not run PR code)

All 72 check runs on the reviewed commit are completed, zero failures. The ubuntu job is the full profile (build, lint, typecheck, unit suite, required integration gate), so the green is substantive; the skipped jobs only run in the merge queue by design on pull_request.

Check Conclusion
Qwen Code CI (workflow run) ✅ success
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped (merge_group-only)
Test (windows-latest, Node 22.x) ⏭️ skipped (merge_group-only)
Integration Tests (CLI, No Sandbox) ⏭️ skipped (merge_group-only)

Not independently verified, and saying so plainly: the author's RED/GREEN and mutation-kill numbers are self-reported — CI independently runs the suite green, but nobody besides the author has run the mutants; and the tests stub the content generator, so no real gateway destroys a socket in this evidence (the PR's own Limits section says the same). Sandboxed verification would settle the residue: @qwen-code /verify — an A/B run against the base build proving the recorded-transcript change is load-bearing. The author lacks write access, so this would be a sponsored run: a maintainer's @qwen-code /verify approves the head it names, and the run carries a pre-execution risk screen plus a full workspace wipe — read its report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

先说独立基线:只看标题和 issue,我的方案也是——把已交付前缀在 processStreamResponse 内、两处持久写入之前合并进响应 parts,收敛到一个共享去重 helper,仅成功路径生效,并删掉冗余的外层合并。这正是 PR 在 round-1 修复后的形态,没有更简的路径被遗漏。

round-1 两个 Critical 在当前 head 的解决方式:

  • R1-1(trim 前后操作数不一致):合并现在作用于原始 part(mergeDeliveredPrefix(prefix, remainderPart.text)),且 contentText 从合并后的 parts 重新计算——记录与 history 来自同一次合并,而不是两个孪生表达式。粘词("The result is" + " 42.")与丢失重叠去重(" total")两类探针场景不再可能在两层间分叉,且各有新测试精确复现。
  • R2-2(中止窗口):两处持久写入现在读取的都是已合并的 parts,"记录已合并"与"history 已合并"之间不再存在悬挂点,因此在 deferred-finishReason chunk 处放弃迭代(Turn.run 中 Esc/abort 的模型)不会再留下"记录已合并、history 只有 remainder"的永久失同步。新增的 deferred-chunk 中止测试钉住了该场景。

关键论断均对照基线树验证过(而非只看 diff):被删的 prependTextToLastModelTurn 只有一个调用点;另外两个 makeApiCallAndProcessStream 调用点不传参 → 行为逐字节不变;合并位于流校验抛错之后,空 continuation 仍会按自身条件校验失败;thought part 在 push 时才前置,所以 unshift 落点正确;resetTransportContinuation 已清理 prefix(无需改动);错误路径(pendingPartialAssistantRecord、部分 history push)由"仅成功"守卫保持 remainder-only;NO_TOOL_RESULT_PROGRESS 中断发生在合并之前,按次值捕获保证不会残留到下一轮。

承接 round-3 的非阻塞建议(均不影响合并):foldTransportAttemptText 可改调 mergeDeliveredPrefix 而不是内联同一算术;无文本 continuation(unshift)分支尚无记录层测试——把 contentText 重算移进 else 的变异体能通过当前测试;合并分支加一条 debugLogger trace 可对齐本文件已有的 [PARTIAL_PUSH] 调查锚点标准。

测试——本 PR 自己的 CI,经 API 读取(审查不运行 PR 代码)

被审 commit 上 72 个 check run 全部完成,零失败。ubuntu 任务为完整档(构建、lint、typecheck、单测、必需集成门禁),绿色是实质性的;被跳过的任务按 pull_request 事件的设计只在 merge queue 运行。

未独立验证的部分,直说:作者的 RED/GREEN 与变异杀灭数字为自述——CI 独立跑绿了测试套件,但变异体除作者外无人跑过;且测试用桩内容生成器,证据中没有真实网关断连(PR 的 Limits 一节自己也这么说)。沙箱验证可以补齐残留缺口:@qwen-code /verify——对 base 构建做 A/B,证明转录记录的改动是承重墙。作者无写权限,所以这将是代跑(sponsored run):由 maintainer 的 @qwen-code /verify 批准对应 head,运行前有预执行风险筛查与全工作区清理——请对报告保持与 fork CI 日志同等的怀疑。

Qwen Code · qwen3.8-max

Reviewed at d4810eab8b0d7f41afa0ff045acdfb86f5c4072a · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean across every stage; both round-1 Criticals are structurally eliminated and pinned by regression tests, with only non-blocking nits left.

Honest read: this is what a good fix in this file looks like. The problem is real and was measured on a live gateway; the fix is the minimal correct shape — one merge point before either durable write, one shared helper encoding the "record and history cannot drift" invariant, success-only, per-attempt lifetime, dead code deleted rather than left dormant. The comment block at the merge site documents both failure modes it exists to prevent, which is the kind of thing a maintainer six months from now thanks the author for. The round-1 review found two real desync bugs; the follow-up commit doesn't patch around them — it removes the class of bug by construction, and the new tests reproduce the exact probe scenarios byte-for-byte. CI is fully green on the reviewed commit (72/72 checks completed, zero failures; PENDING = 0).

Reservations, named: the round-3 suggestions are still worth taking (recorder-layer test for the unshift branch, foldTransportAttemptText reusing the helper, one trace line) — a future edit could reintroduce through the side door what this PR nails shut, and those three items close that door further. The body skips the template's headings and the bilingual section; the content is all there, so I'm not blocking on form. And no test in this PR has ever seen a real socket die — inherent to the area, acknowledged in Limits, and the continuation machinery itself predates this PR.

Process note for @wenshao: your round-1 changes-requested review is still standing on this PR. The fix commit (a65cb736) landed after it and addresses R1-1 and R2-2 as probed; my approval does not replace your re-review — please confirm the fixes settle your findings to your satisfaction before dismissing.

Approving, pinned to the reviewed commit.

中文说明

置信度:4/5 —— 各阶段均干净;round-1 的两个 Critical 已从结构上消除并由回归测试钉住,只剩非阻塞建议。

直说:这是这个文件里一个优秀修复的样子。问题真实存在,有真实网关上的测量;修复是最小的正确形态——两处持久写入之前的单一合并点、一个编码"记录与 history 不得漂移"不变量的共享 helper、仅成功路径、按次生命周期、死代码删除而非留置。合并点处的注释记录了它要防的两类失效,这是六个月后的维护者会感谢作者的东西。round-1 发现了两个真实的失同步 bug;后续 commit 没有绕着打补丁,而是从构造上消除了这一类 bug,新测试逐字节复现了探针场景。被审 commit 上 CI 全绿(72/72 check 完成,零失败;PENDING = 0)。

保留意见,明说:round-3 的建议仍然值得采纳(unshift 分支的记录层测试、foldTransportAttemptText 复用 helper、一条 trace 日志)——未来的改动可能把这个 PR 钉死的问题重新带回来,这三项把门关得更严。正文没用模板标题、缺中文部分;内容齐全,不因形式阻塞。本 PR 的测试也没有见过真实 socket 断开——该领域固有,Limits 已声明,continuation 机制本身也早于本 PR。

@wenshao 的流程提示:你 round-1 的 changes-requested 仍挂在 PR 上。修复 commit(a65cb736)在其之后落地,按探针场景解决了 R1-1 与 R2-2;我的批准不替代你的复审——请确认修复满足你的标准后再撤销。

批准,锚定到被审 commit。

Qwen Code · qwen3.8-max

Reviewed at d4810eab8b0d7f41afa0ff045acdfb86f5c4072a · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Maintainer deep verification (local run by @wenshao)

Verdict: merge-ready — 72/72 scripted assertions passed, 0 failed. Verified head d4810eab against control d5e47709 (the PR's recorded base, an ancestor of head — base..head is exactly this PR). CI on this PR is entirely skipping pending authorization, so I ran the gates locally. The PR head is 30 commits behind current main (4b45f96a), which touched both files heavily in the meantime — so I also verified the merge, not just the PR: trial merge of d4810eab into 4b45f96a is conflict-free and yields exactly this diff, and geminiChat.test.ts on the merged tree is 316/316 green.

中文摘要(点击展开)

结论:可合并——72/72 条脚本化断言全部通过,0 失败。PR head 落后当前 main 30 个提交且 main 期间大改了同两个文件,因此额外验证了合并本身:试验性合并无冲突且结果恰为本 PR 的 diff,合并树上 geminiChat.test.ts 316/316 全绿。

  • A/B 负载证明:同一套 PR 测试,在 base 源码上恰好 5 个新跨层测试全红(失败消息逐条核对,均为"只记录了续传残段"的预期形态),head 上 22/22 全绿,完整 geminiChat.test.ts 307/307。见下图 01。
  • 真实链路验证(wire oracle):用编译产物驱动真实 GeminiChat + 真实 ChatRecordingService(真实写 JSONL)+ 真实 SessionService.loadSession/buildApiHistoryFromConversation 投影(--resume 实际使用的代码路径)。base 复现 Transport-continuation recovery leaves the resumed transcript starting mid-sentence #8094:JSONL 只存残段 "second half",resume 从句子中间开始;head 三层一致 "first half second half"。7 场景 × 双臂共 63 条断言全过。见下图 02(底部为原始 JSONL 对比)。
  • Mutation 矩阵:5/5 变异体全部被其对应测试杀死(含 2 个额外阳性对照),证明测试非空转。见下图 03。
  • 门禁:blast-radius 四个测试文件 408/408;tsc --noEmit 通过。
  • Findings:无阻塞项。两处既有行为观察(非本 PR 引入):续传重放重叠时实时流原样显示重叠(仅持久层去重);续传不可重试失败时已交付文本两层都不保留(两层一致,PR 已声明)。
  • 未覆盖:真实 TCP 断流(与 PR 自证一样在模型边界用假 generator);真实 TUI --resume(已在其底层同一投影代码上验证);全仓测试。
  • 环境说明:本机无容器运行时,改为全量人工审计 diff(仅 2 文件,纯字符串/记录逻辑+测试,无网络/文件/子进程)后在隔离 worktree 执行;base↔head lockfile 逐字节相同。

Central claim (verified)

After a socket cut mid-response, the continuation path (#7832) merged the delivered prefix into in-memory history only; the durable JSONL record kept just the resumed remainder, so --resume/--continue rehydrated a turn starting mid-sentence (#8094). The fix folds the prefix into the parts inside processStreamResponse before both durable writes, so transcript and history derive from the same data.

A/B load-bearing proof

Same PR test file against base source (control) vs head source; the control must go red on exactly the five new cross-layer tests — a scripted comparison, not an impression:

Cell Result Oracle
base d5e47709 + PR tests 5 failed / 17 passed (block); each failure message is the remainder-only shape (expected 'second half' to be 'first half second half', '42.' vs 'The result is 42.', …) bug reproduces
head d4810eab 22/22 passed (block); full file 307/307 fixed

A/B: 5 new cross-layer tests RED at base, all GREEN at head

Wire oracle — real dist, real JSONL, real resume projection

harness-resume-oracle.mjs drives the compiled GeminiChat with a real ChatRecordingService writing a real JSONL transcript, then reloads it through the real SessionService.loadSession + buildApiHistoryFromConversation (the exact code --resume uses). Only the model boundary is faked — the same seam as the PR's own tests. 7 scenarios × both arms = 63 scripted assertions, all passing:

Scenario base (control) head
cut after first half , continuation second half record second half; resume starts mid-sentence; history/transcript desync record = history = resume = first half second half
whitespace-leading remainder 42. record 42. (trimmed) The result is 42. — no word fusion
continuation replays overlap record = remainder verbatim record deduped identically to history
double cut, then success single record, final remainder only single record, fully merged across both cuts
continuation yields functionCall then dies identical on both arms: no text part recorded, prefix in neither layer — success-only gate holds same
continuation opens with a thought part record remainder-only merge lands in first plain-text part, thought intact
prefix delivered in several chunks record remainder-only full prefix carried

Raw durable JSONL from the run (bottom of the capture): head [{"text": "first half second half"}] vs base [{"text": "second half"}].

Wire oracle: base reproduces #8094, head fixes it — with raw JSONL

Mutation matrix (vacuity check)

Five single-point mutants against head source, each driven against the test that should pin it — 5/5 killed, including two positive controls I added (m4 word-fusion via trimmed remainder; m5 isContinuation flip against a pre-existing #7832 assertion):

Mutation matrix: 5/5 killed

Corrections to the PR description (drift, not code issues)

  • The body's RED proof says "2 failed | 16 passed"; at the verified head the block has 22 tests and 5 go red on base — later commits added the whitespace/abort/dedup cases after the body was written.
  • Call-site line numbers (:2696/:3225/:3899) drifted to :2717/:3249/:3923; the structural claim (3 call sites, only the continuation loop threads the prefix) is accurate — I audited all three plus the single processStreamResponse caller, and confirmed no prependTextToLastModelTurn references remain.

Findings

None blocking. Two observations, both pre-existing and unchanged by this PR:

  1. When the continuation replays overlap, the live chunk stream shows it verbatim on both arms (The quick brown foxbrown fox jumps over…) — the UI cannot un-show delivered text; only the durable layers dedup. YOLO mode: mid-stream socket close is not retried, making large code generation impossible #7832 behavior, out of scope here.
  2. The PR-declared limit is real and consistent: an unretryable continuation failure keeps the delivered text in neither layer (scenario f exercises the adjacent tool-call-cut shape; layers agree).

Not covered

  • Real TCP socket destruction — harness fakes the model boundary (same seam as the PR's own proof); reproduces the wire shape, not the network cause. The original socket-level measurements in Transport-continuation recovery leaves the resumed transcript starting mid-sentence #8094 stand.
  • --resume in a real TUI — covered at the exact projection code the CLI resume path uses instead.
  • Repo-wide suite — scoped gates run locally instead: geminiChat.test.ts 307/307 at head and 316/316 on the trial-merge tree into current main; blast-radius files (client, chatRecordingService ×2, goal-turn-integration) 408/408; tsc --noEmit exit 0.
  • Per-commit attribution — verified the aggregate base..head diff.
  • Wire-oracle on the merged tree — the merged tree was verified at source level (suite green); the dist-level harness ran on head/base only.

Methodology

macOS, Node v22.22.2. No container runtime available, so instead of the sandboxed lane: full-diff audit first (2 files, pure string/record logic + tests; no manifests/scripts/network/fs/process execution), then isolated git worktrees for head (d4810eab) and base (d5e47709). base↔head lockfiles are byte-identical (verified), so the base tree shares the head npm ci install via symlink; readlink checks confirmed @qwen-code/* links point into head, so the harness imports dist by absolute path only, and packages/core has no internal workspace deps. All harnesses/logs preserved under tmp/pr8624-verify-20260808-000730/ (harness: harness-resume-oracle.mjs; captures via scripts/verify-capture.mjs; images on branch pr-assets/8624-verify-local).

@wenshao
wenshao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into QwenLM:main with commit efc7ec7 Aug 7, 2026
104 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.8.

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.

3 participants