🧮 fix: Stop Offsetting Once SYNC Drops the Edited Prefix

The edit offset was applied unconditionally, but whether it is correct
depends on which branch SYNC took. SYNC either preserves the content
already loaded for the response -- which still contains the retained
prefix, so the offset is required -- or replaces it with the server's
aggregatedContent, which is completion-local and indexed from zero, after
which any offset writes past the end of a now shorter array.

That is why the two previous attempts each fixed half of it: skipping the
offset on resume was right for the replace branch, applying it
unconditionally was right for the preserve branch, and neither holds on its
own. The offset now tracks the actual state of the rendered content.

For an activity label the replace branch was worse than a bad position:
the fill landed past its own reservation, so the pending placeholder was
never resolved and the block kept its generic header for the rest of the
run.

Applied to run steps as well, not just labels. useStepHandler reads the
prefix from the same submission and had the same unconditional offset, so
after a mid-session resume of an edited response tool cards were misplaced
too. Normalizing at the dispatch boundary keeps both in ONE index space by
construction: a label that shifted differently from the tools it heads
would land on another part.

Note the reload path was already coherent -- useResumeOnLoad rebuilds the
submission without editedContent or editPrefixLength, giving no offset
against server-supplied content -- so only the mid-session SYNC path was
inconsistent.
This commit is contained in:
Danny Avila 2026-07-27 01:25:20 -04:00
parent c7db006903
commit 57e45cbb67

View file

@ -550,6 +550,13 @@ export default function useResumableSSE(
* two retries can't cancel each other. */
const steerRetryRef = useRef<number | null>(null);
const activityLabelRetryRef = useRef<number | null>(null);
/**
* Set once a SYNC has replaced the response with the server's
* completion-local snapshot, which discards the prefix an edited
* resubmission retained. From that point incoming indices are absolute and
* `editPrefixLength` must no longer be applied by run steps or labels.
*/
const editPrefixClearedRef = useRef(false);
/** Removes the pending chip once its steer is injected (the inline content
* part becomes the durable record), and records the id so a 202 ACK that
@ -625,7 +632,7 @@ export default function useResumableSSE(
const setRunEnd = useSetRecoilState(store.runEndByIndex(runIndex));
const {
stepHandler,
stepHandler: rawStepHandler,
finalHandler,
errorHandler,
clearStepMaps,
@ -648,6 +655,24 @@ export default function useResumableSSE(
setShowStopButton,
});
/**
* Run steps read the edit prefix from the submission, so once a SYNC has
* replaced the response with the completion-local snapshot they must stop
* offsetting the prefix those indices were relative to is gone. Applied
* here rather than at each dispatch so every path (live, replay, pending)
* agrees, and so run steps and activity labels share one index space.
*/
const stepHandler = useCallback<typeof rawStepHandler>(
(event, submission) =>
rawStepHandler(
event,
editPrefixClearedRef.current && submission != null
? ({ ...submission, editPrefixLength: 0 } as typeof submission)
: submission,
),
[rawStepHandler],
);
const { data: startupConfig } = useGetStartupConfig();
const balanceQuery = useGetUserBalance({
enabled: !!isAuthenticated && startupConfig?.balance?.enabled,
@ -821,7 +846,7 @@ export default function useResumableSSE(
* another part, and a gap fill would miss its own reservation and
* leave the placeholder pending forever. */
const prefixLength =
currentSubmission.editedContent != null
currentSubmission.editedContent != null && !editPrefixClearedRef.current
? (currentSubmission.editPrefixLength ??
(currentSubmission.initialResponse as TMessage | undefined)?.content?.length ??
0)
@ -1113,6 +1138,19 @@ export default function useResumableSSE(
matchedByResponseId &&
Array.isArray(oldContent) &&
oldContent.length > 0;
/**
* Replacing the response with `aggregatedContent` drops the
* prefix an edited resubmission had retained: the snapshot is
* completion-local, indexed from zero. Every later event must
* therefore stop offsetting, or it writes past the end of the
* shorter array for an activity label that means the fill
* misses its own reservation and the placeholder never
* resolves. Preserving `oldContent` keeps the prefix, and with
* it the offset. Run steps and labels both read this.
*/
if (!preserveLoadedContent) {
editPrefixClearedRef.current = true;
}
const responseMessage = {
...messages[responseIdx],
content: preserveLoadedContent ? oldContent : data.resumeState.aggregatedContent,