VS Code Review Changes concatenates intermediate fileChange patches when turn.diff is missing
What version of the IDE extension are you using?
26.803.61601
What subscription do you have?
ChatGPT Plus
Which IDE are you using?
Visual Studio Code 1.130.0 (Remote SSH)
What platform is your computer?
Client: Microsoft Windows NT 10.0.22631.0 x64; Remote: Linux 5.13.0-39-generic x86_64
What issue are you seeing?
Summary
For a restored historical turn, Review Changes can show a stale and inflated diff that does not match the final working tree or Git.
In one reproducible turn, the same file was edited five times. Some later patches revised or reverted earlier changes. The final Git diff was:
113 insertions, 17 deletions
Codex Review Changes displayed:
199 insertions, 103 deletions
The displayed review also started at an intermediate function, while the final Git diff started at a function added by the last patch.
Root cause
The restored turn had an empty turn.diff, so the extension fell back to rebuilding the review from its fileChange items.
In the current webview bundle, the fallback collects successful patch batches and calls the equivalent of TAe(patchBatches). For repeated updates to the same path, that function appends each later @@ hunk to the previously rendered patch:
existingPatch = existingPatch + "\n" + laterHunks;
This concatenates the edit history rather than composing the patches. Overlapping edits, revisions, and reversions are therefore counted and rendered multiple times.
The erroneous 199/103 counts exactly matched the sum of the five raw intermediate fileChange patch batches. This rules out Git's diff algorithm, line endings, duplicate paths, or a symlink as the cause.
Context rendering
The original fileChange.unified_diff hunks normally retain one unchanged line before and one unchanged line after each changed region (or only the preceding line at end of file). The fallback should preserve this review context when it generates the composed baseline-to-final diff. A zero-context result fixes the counts but degrades Review Changes by hiding the surrounding code needed to understand each edit.
What steps can reproduce the bug?
- Open a Git repository in the VS Code Codex extension.
- Start one Codex turn that edits the same file multiple times.
- Ensure later edits overlap, revise, or revert lines changed by earlier edits.
- Complete the turn.
- Reload the Codex webview or restore the historical conversation in a state where the persisted
turn.diffis empty. - Open Review Changes for that turn.
- Compare its hunks and line counts with the final file state and
git diff --numstat.
A minimal conceptual sequence is:
patch 1: add function A and modify several call sites
patch 2: revise part of function A
patch 3: revert some formatting from patch 1
patch 4: revise overlapping call sites
patch 5: add function B before A and replace calls
The review fallback shows portions of patches 1-5 as cumulative changes instead of the single baseline-to-final diff.
What is the expected behavior?
Review Changes should display the latest aggregated diff from the turn baseline to the final state.
When turn.diff is unavailable, repeated fileChange updates for the same file must be composed in order. Intermediate edits that were later revised or reverted must disappear from the final review. The resulting patch should apply cleanly to the turn baseline and reproduce the final file.
The composed hunks should retain the same surrounding context as the original review path: normally one unchanged line before and after each changed region, with the obvious boundary exception at end of file. Repeated equal-content lines, such as adjacent blank lines, must be aligned deterministically so context comes from the correct location.
Additional information
Suggested fix
Do not concatenate hunk text for repeated updates to the same file.
For the fallback path, maintain a per-file virtual state:
- Build the baseline and current representation from the first patch.
- Apply subsequent unified diffs in order.
- Generate one baseline-to-final unified diff after all batches are composed.
- Match equal-content lines deterministically, preferring surviving baseline-line identity when multiple alignments produce the same number of content matches. This prevents repeated blank lines from moving context to the wrong location.
- Retain the review's existing hunk context, normally one unchanged line before and after each changed region. Do not emit a zero-context diff merely to obtain correct statistics.
- If any patch cannot be composed safely, use a recoverable "diff unavailable" state or request/recompute an aggregated diff instead of showing a known-invalid concatenation.
- Prefer persisting and using the app-server's aggregated
turn.diffwhenever available.
I validated this approach against the five real patch batches:
- raw concatenation:
+199/-103 - composed fallback:
+113/-17 - Git:
+113/-17 - context: 9 normal hunks retained one line before and after; the final end-of-file hunk retained one preceding line
- replay: the composed patch reproduced the final file byte-for-byte from the reconstructed turn baseline
A content-only alignment initially produced correct counts but could attach context to the wrong one of two adjacent blank lines. A content-first alignment with surviving baseline identity as the tie-break retained both the correct +113/-17 result and stable context.
This appears related in symptom to #11909 and #12786, but those reports do not identify the missing-turn.diff plus repeated-fileChange concatenation path described here.
Reference implementation outline
The exact local workaround modified a generated/minified webview bundle, so attaching that version-specific bundle patch would not be suitable as an upstream fix. The source-level behavior can be implemented along these lines:
type VirtualLine = {
// Present for lines inherited from the turn baseline.
baselineId?: number;
// Unknown is allowed outside the context exposed by fileChange hunks.
text?: string;
};
type FileState = {
baseline: VirtualLine[];
current: VirtualLine[];
};
function composeFileChanges(batches: PatchBatch[]): string | null {
const states = new Map<FileKey, FileState>();
for (const batch of batches) {
for (const change of batch.changes) {
if (change.type !== "update" || change.movePath != null) {
handleAddDeleteOrMoveWithoutUsingTheUpdateAccumulator(change);
continue;
}
const state =
states.get(change.fileKey) ??
buildVirtualBaselineAndCurrentFromFirstPatch(change.unifiedDiff);
if (state == null || !applyUnifiedDiffInOrder(state.current, change.unifiedDiff)) {
// Never concatenate the raw hunks after composition has failed.
return null;
}
states.set(change.fileKey, state);
}
}
return renderBaselineToFinalDiff(states, {
contextLines: 1,
lineMatch: {
primary: "maximize equal text matches",
tieBreak: "prefer the same surviving baselineId",
},
});
}
The baselineId tie-break is important. With repeated equal-content lines such as adjacent blank lines, content-only matching can select the wrong occurrence and attach otherwise valid context to the wrong location.
If the project already has a tested patch/diff library, using it for sequential patch application and final diff generation would be preferable to maintaining a custom parser.
Suggested regression tests
- Two non-overlapping updates to one file produce one final diff.
- A later patch rewrites lines added by an earlier patch; only the final text appears.
- A later patch reverts an earlier formatting change; the reverted change disappears from the final diff and statistics.
- A final patch inserts a new function before an earlier edit; hunk locations and order remain correct.
- Adjacent blank lines retain stable context from the correct location.
- An end-of-file deletion retains preceding context without requiring following context.
- Add, delete, and move operations do not enter the plain-update accumulator incorrectly.
- A malformed or non-composable patch returns a recoverable unavailable/recompute result and never falls back to raw hunk concatenation.
For each successful case, assert all of the following:
- the composed insert/delete counts equal the baseline-to-final Git diff;
- applying the composed patch to the reconstructed turn baseline reproduces the final file byte-for-byte;
- ordinary hunks retain one unchanged line before and after the changed region;
- intermediate or reverted edits are absent from the rendered review.
1 Comment
Follow-up performance note:
While validating the proposed composition approach on a separate long restored
thread, I found that recomputing and rendering the baseline-to-current diff
after every
fileChangebatch can make Review Changes noticeably slow.The thread contained a 141 MB rollout, 613 patch events, and about 581 unified
diff batches. The VS Code remote extension host briefly reached about 64% CPU
while opening Review Changes.
The implementation remained semantically equivalent but was changed to:
surviving baseline-line identity as the tie-break;
sets.
Validation:
This does not change the proposed fix in the issue; it is an implementation
detail that avoids turning the correct fallback into a performance regression
for long restored threads.