Skip to content

🫙 fix: Preserve Loaded Message Content When Resume Snapshot Is Empty#14399

Merged
danny-avila merged 3 commits into
devfrom
claude/preserve-resume-content
Jul 23, 2026
Merged

🫙 fix: Preserve Loaded Message Content When Resume Snapshot Is Empty#14399
danny-avila merged 3 commits into
devfrom
claude/preserve-resume-content

Conversation

@danny-avila

@danny-avila danny-avila commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

The resumable-stream sync handler guarded on data.resumeState?.aggregatedContent and then assigned it unconditionally. An empty array is truthy, so a resume snapshot carrying no content overwrote content already loaded for that same response — the message then rendered as a bare cursor even though the conversation's own history had the text.

An empty snapshot is now treated as non-authoritative for a response we can identify, and the loaded content is kept.

+ const hasResumedContent = data.resumeState.aggregatedContent.length > 0;
+ const preserveLoadedContent = !hasResumedContent && matchedByResponseId;
...
- content: data.resumeState.aggregatedContent,
+ content: preserveLoadedContent ? oldContent : data.resumeState.aggregatedContent,

Scope

Behavior differs from dev only when both conditions hold:

  1. the row was located by an exact resumeState.responseMessageId match, and
  2. aggregatedContent is empty

In that case the loaded content is preserved instead of cleared. Every other path is unchanged — non-empty snapshots, the else branch that creates a new response row, and any row located by the parent-based fallback all behave exactly as before. The change can only ever skip a write that previously happened; it never writes anything new.

Concretely, this covers a response whose partial was persisted under the same id and whose snapshot subsequently comes back empty. It is a robustness guard on the resume path, not a fix for any one root cause: an empty snapshot on a live run is itself worth investigating separately (see the note below), and this only ensures that when it happens, already-visible content is not destroyed.

Why preservation is restricted to an id match

Regenerate makes the parent-based fallback unsafe. The client derives the regenerated response id by padding the previous one (useChatFunctions.ts):

return `${responseMessageId.replace(/_+$/, '')}_`;

and the server adopts that value verbatim (getPreliminaryResponseMessageId in request.js). So mid-regenerate the job carries oldRespId_ while loaded history still holds oldRespId. The exact-id lookup cannot match, the fallback fires, and it selects the answer being replaced. Preserving that row's content would seed syncStepMessage with the stale response and make the regenerated run's deltas append to it.

Requiring an id match is what makes preservation safe: it is the only condition proving the row belongs to the generation the snapshot describes. Deliberately not done here: teaching the lookup to also try the unpadded id. That would widen behavior on this hot path without evidence demanding it, and regenerate is precisely where that would go wrong.

Thanks @chatgpt-codex-connector for catching the regenerate interaction — it was a real regression in the first revision of this PR.

Related

A resume snapshot can go empty when a conversation's job is replaced mid-flight: because streamId === conversationId, a second submission calls createJob on the same key while the first run is still streaming. On the in-memory job store the first run's content then becomes unreachable (appendChunk is gated behind if (this._isRedis), and setContentParts replaces the stored array reference), so getResumeState reports aggregatedContent: [] and runSteps: [] while active stays true. On Redis the same replacement is survivable, since JOB_CREATE_LUA clears only the steer keys and the chunk log is reconstructed.

That is the ambiguity tracked in #14348, whose clean fix (per-generation stream ids) remains open. This PR does not address it — a replacement carries its own responseMessageId, so the preserve path above does not apply to it.

Test plan

Three tests in useResumableSSE.spec.ts:

  • empty snapshot preserves loaded content on an id-matched row
  • empty snapshot still clears a row matched only by the parent fallback (the regenerate case)
  • a snapshot carrying content still applies

Each guard verified non-vacuous by reverting the corresponding change and confirming the test fails, then passes with it restored.

npx jest src/hooks/SSE → 7 suites, 149 tests passing.

The sync handler guarded on `data.resumeState?.aggregatedContent` and then
assigned it unconditionally. An empty array is truthy, so a resume snapshot
carrying no content overwrote the content the messages query had already
loaded, leaving the message rendering as a bare cursor with no way to recover.

Treat an empty snapshot as non-authoritative: keep the loaded content and let
live deltas take over. A snapshot that carries content still wins, unchanged.

This is defense in depth rather than a root cause. A resume snapshot goes empty
when the conversation's job is replaced mid-flight (#14348) — because
`streamId === conversationId`, a second submission calls `createJob` on the same
key while the first run is still streaming. With this change that failure
degrades to stale-but-visible instead of destroying content already on screen.
Copilot AI review requested due to automatic review settings July 23, 2026 03:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a client-side resumable-stream sync edge case where an empty resumeState.aggregatedContent snapshot could overwrite already-loaded message content (from the messages query), causing the UI to render a “bare cursor” response with no recoverable text.

Changes:

  • Treat empty resume snapshots as non-authoritative by preserving the existing message content when aggregatedContent.length === 0.
  • Add Jest coverage to ensure empty snapshots preserve loaded content and non-empty snapshots still replace content as before.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
client/src/hooks/SSE/useResumableSSE.ts Preserve existing loaded message content when a resume sync snapshot has empty aggregatedContent; add diagnostic log field.
client/src/hooks/SSE/tests/useResumableSSE.spec.ts Add tests covering empty vs non-empty resume snapshot reconciliation behavior.
Comments suppressed due to low confidence (1)

client/src/hooks/SSE/tests/useResumableSSE.spec.ts:1314

  • This test flushes microtasks with an inline act(Promise.resolve()) block even though the file already defines a flushMicrotasks() helper. Using the helper reduces repetition and keeps async timing consistent across tests.
      await act(async () => {
        await Promise.resolve();
      });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return { submission, chatHelpers };
};

const emitSync = async (aggregatedContent: unknown[]) => {
Comment on lines +1299 to +1301
await act(async () => {
await Promise.resolve();
});
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbe97858cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/hooks/SSE/useResumableSSE.ts Outdated
const responseMessage = {
...messages[responseIdx],
content: data.resumeState.aggregatedContent,
content: hasResumedContent ? data.resumeState.aggregatedContent : oldContent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid preserving stale regenerate content

When a user reloads during an in-flight regenerate before the new run has aggregated any content, the server can report an empty aggregatedContent with a padded responseMessageId, while the loaded DB history still contains the old assistant response under the same parent. This fallback can select that old response, and this line now seeds syncStepMessage with its old content; subsequent text deltas append to that stale answer instead of starting the regenerated response from blank.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch, fixed in 7915c15.

You're right that the parent-based fallback can land on the answer being regenerated: it matches any !isCreatedByUser row whose parentMessageId === userMsgId, and on a regenerate the new run's responseMessageId isn't in the loaded history yet, so that fallback is exactly what fires.

Preservation is now scoped to rows matched by the server's declared responseMessageId, which is the only case that proves the row belongs to this generation:

let matchedByResponseId = false;
if (serverResponseId) {
  responseIdx = messages.findIndex((m) => m.messageId === serverResponseId);
  matchedByResponseId = responseIdx >= 0;
}
// ...
const preserveLoadedContent = !hasResumedContent && matchedByResponseId;

A fallback-matched row keeps the previous clear-on-empty behavior, so a regenerated run still starts blank. This also keeps the original fix effective for the case it targets, since a resumed placeholder and a persisted partial both carry the server's response id.

Added does not preserve content on a row matched only by the parent fallback to cover it; verified it fails against the previous version of the guard and passes now.

Codex P2: on a regenerate reload before the new run aggregated anything, the
server reports an empty snapshot under a response id not yet in the loaded
history. The parent-based fallback then lands on the answer being REPLACED, and
preserving its content seeded `syncStepMessage` with the stale response, so the
regenerated run's deltas appended to it instead of starting blank.

Preserve loaded content only when the row was matched by the server's declared
`responseMessageId` — the sole case proving the row belongs to this generation.
A fallback-matched row keeps the previous clear-on-empty behavior.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 7915c15c68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila danny-avila changed the title 🛟 fix: Keep Loaded Message Content When a Resume Snapshot Is Empty 🫙 fix: Preserve Loaded Message Content When Resume Snapshot Is Empty Jul 23, 2026
A matched row with no `content` array would have been assigned `undefined`
instead of the snapshot's array. `MultiMessage` branches on `message.content`
truthiness to pick its renderer, and `[]` is truthy while `undefined` is not, so
that would have silently switched a streaming row from the content-parts
renderer to the text one.

Preserve only when the loaded row has a non-empty content array — the case the
guard exists for. Narrows the divergence from prior behavior further: it now
applies solely when there is real content to protect.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 9a3beeb351

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila merged commit 60eba76 into dev Jul 23, 2026
18 checks passed
@danny-avila
danny-avila deleted the claude/preserve-resume-content branch July 23, 2026 12:22
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.

2 participants