diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index b0fedd00679..ad672d32381 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -281,6 +281,7 @@ const CONV_ID = 'conv-abc-123'; type PartialSubmission = { conversation: { conversationId?: string }; isRegenerate?: boolean; + compact?: boolean; editedContent?: Record; editPrefixLength?: number; clientRequestId?: string; @@ -3796,6 +3797,217 @@ describe('useResumableSSE', () => { unmount(); }); + it('keeps a compaction anchor intact when a resumed sync carries its user-message slot', async () => { + /** A manual compaction submits no user turn: its `userMessage` slot holds + * the LEAF it summarizes up to (see `useCompactConversation`), and the + * server's job metadata projects that anchor the same way — identity + * only, no parent, no author. Merging it as a row would rewrite the + * answer it points at into an empty, parentless user message, which the + * message tree renders as a phantom root: the thread folds. */ + const originalUser = { + messageId: 'original-user', + conversationId: CONV_ID, + text: 'Original prompt', + isCreatedByUser: true, + sender: 'User', + parentMessageId: String(Constants.NO_PARENT), + }; + const anchor = { + messageId: 'original-response', + conversationId: CONV_ID, + text: 'Original response', + isCreatedByUser: false, + sender: 'Assistant', + parentMessageId: 'original-user', + }; + const summaryPlaceholder = { + messageId: 'original-response_', + conversationId: CONV_ID, + text: '', + isCreatedByUser: false, + sender: 'Assistant', + parentMessageId: 'original-response', + content: [], + }; + const submission = { + ...buildSubmission({ + conversation: { conversationId: CONV_ID }, + /** Exactly what `useChatFunctions` builds for a compaction. */ + userMessage: { + messageId: 'original-response', + parentMessageId: 'original-response', + conversationId: CONV_ID, + text: '', + isCreatedByUser: true, + sender: 'User', + }, + initialResponse: summaryPlaceholder, + isRegenerate: true, + compact: true, + messages: [originalUser, anchor] as TMessage[], + }), + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([ + originalUser, + anchor, + summaryPlaceholder, + ] as TMessage[]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + + await act(async () => { + await Promise.resolve(); + }); + + const sse = getLastSSE(); + await act(async () => { + sse._emit('message', { + data: JSON.stringify({ + sync: true, + resumeState: { + runSteps: [], + replayEvents: [], + aggregatedContent: [ + { + type: ContentTypes.SUMMARY, + summary: { content: [{ type: 'text', text: 'Earlier turns, compacted.' }] }, + }, + ], + responseMessageId: 'original-response_', + conversationId: CONV_ID, + isRegenerate: true, + /** `projectCompactionAnchor` on the server: identity only. */ + userMessage: { + messageId: 'original-response', + conversationId: CONV_ID, + text: '', + }, + }, + }), + }); + }); + + const lastMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + expect(lastMessages.map((message) => message.messageId)).toEqual([ + 'original-user', + 'original-response', + 'original-response_', + ]); + /** The anchor is still the answer it always was. */ + expect(lastMessages[1]).toEqual( + expect.objectContaining({ + messageId: 'original-response', + parentMessageId: 'original-user', + text: 'Original response', + isCreatedByUser: false, + }), + ); + /** ...and the summary still hangs off it. */ + expect(lastMessages[2]).toEqual( + expect.objectContaining({ + messageId: 'original-response_', + parentMessageId: 'original-response', + isCreatedByUser: false, + }), + ); + + unmount(); + }); + + it('keeps a user-leaf compaction anchor intact when the submission carries no flag', async () => { + /** A compaction anchored on a user turn, re-attached by a submission that + * never learned it was one. The projection is identity-only either way, so + * merging it as a row blanks the prompt still on screen. The anchor is + * recognized from that SHAPE, not from a flag the submission may not carry. */ + const rootUser = { + messageId: 'root-user', + conversationId: CONV_ID, + text: 'Original prompt', + isCreatedByUser: true, + sender: 'User', + parentMessageId: String(Constants.NO_PARENT), + }; + const userLeaf = { + messageId: 'user-leaf', + conversationId: CONV_ID, + text: 'Compact this before I continue', + isCreatedByUser: true, + sender: 'User', + parentMessageId: 'root-user', + }; + const summaryPlaceholder = { + messageId: 'user-leaf_', + conversationId: CONV_ID, + text: '', + isCreatedByUser: false, + sender: 'Assistant', + parentMessageId: 'user-leaf', + content: [], + }; + const submission = { + ...buildSubmission({ + conversation: { conversationId: CONV_ID }, + userMessage: userLeaf, + initialResponse: summaryPlaceholder, + isRegenerate: true, + messages: [rootUser, userLeaf] as TMessage[], + }), + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([rootUser, userLeaf, summaryPlaceholder] as TMessage[]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + + await act(async () => { + await Promise.resolve(); + }); + + const sse = getLastSSE(); + await act(async () => { + sse._emit('message', { + data: JSON.stringify({ + sync: true, + resumeState: { + runSteps: [], + replayEvents: [], + aggregatedContent: [ + { + type: ContentTypes.SUMMARY, + summary: { content: [{ type: 'text', text: 'Earlier turns, compacted.' }] }, + }, + ], + responseMessageId: 'user-leaf_', + conversationId: CONV_ID, + isRegenerate: true, + userMessage: { messageId: 'user-leaf', conversationId: CONV_ID, text: '' }, + }, + }), + }); + }); + + const lastMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + /** One row per id: the anchor is never appended a second time. */ + expect(lastMessages.map((message) => message.messageId)).toEqual([ + 'root-user', + 'user-leaf', + 'user-leaf_', + ]); + /** The prompt survives the resume. */ + expect(lastMessages[1]).toEqual( + expect.objectContaining({ + messageId: 'user-leaf', + parentMessageId: 'root-user', + text: 'Compact this before I continue', + isCreatedByUser: true, + }), + ); + + unmount(); + }); + it('uses the resumed submission for final events after sync', async () => { const originalUser = { messageId: 'original-user', diff --git a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx index 9104c4c6b68..ccd329b5190 100644 --- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx +++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx @@ -2118,6 +2118,220 @@ describe('useResumeOnLoad', () => { expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(1); }); + /** + * A manual compaction submits no user turn: the server projects the LEAF it + * summarizes up to into the job's user-message slot, identity only + * (`projectCompactionAnchor`). Resuming one must adopt that leaf, not + * synthesize an empty root-parented USER row over it — the row it names is + * usually the assistant answer, and rewriting it detaches every turn above it. + */ + it('resumes a compaction against the anchor it summarizes up to', async () => { + const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); + const anchor = { + messageId: 'branch-one-answer', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'Branch one answer', + isCreatedByUser: false, + } as TMessage; + const newerSibling = { + messageId: 'branch-two-answer', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'Branch two answer', + isCreatedByUser: false, + } as TMessage; + const observedSiblingIndexes: number[] = []; + const observedSubmissions: Array = []; + + mockUseStreamStatus.mockReturnValue({ + isSuccess: true, + isFetching: false, + data: { + active: true, + status: 'running', + streamId: CONVERSATION_ID, + resumeState: { + runSteps: [], + aggregatedContent: [], + replayEvents: [], + responseMessageId: `${anchor.messageId}_`, + conversationId: CONVERSATION_ID, + isRegenerate: true, + userMessage: { + messageId: anchor.messageId, + conversationId: CONVERSATION_ID, + text: '', + }, + }, + }, + }); + + renderUseResumeOnLoad({ + messages: [rootUser, anchor, newerSibling], + siblingIndexParentId: rootUser.messageId, + onSiblingIndex: (siblingIndex) => observedSiblingIndexes.push(siblingIndex), + onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission), + }); + + await act(async () => { + await Promise.resolve(); + }); + + const submission = observedSubmissions[observedSubmissions.length - 1]; + /** The slot keeps the anchor's own identity. */ + expect(submission?.userMessage).toEqual( + expect.objectContaining({ + messageId: anchor.messageId, + parentMessageId: rootUser.messageId, + text: anchor.text, + isCreatedByUser: false, + }), + ); + /** ...is marked as the anchored run it is, so no handler writes it as a row, */ + expect(submission?.compact).toBe(true); + /** ...stays in the history the run replays, */ + expect((submission?.messages ?? []).map((message) => message.messageId)).toEqual([ + rootUser.messageId, + anchor.messageId, + newerSibling.messageId, + ]); + /** ...and the summary hangs off it. */ + expect(submission?.initialResponse?.parentMessageId).toBe(anchor.messageId); + /** The compaction runs on the branch it was started from, not the newest. */ + expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(1); + }); + + /** + * Compact runs on whatever leaf the branch ends with and `canCompact` does not + * restrict that leaf's author, so the anchor is a USER message whenever the + * branch ends in one (the `compaction-on-user-turn` scenarios cover the + * rendered form). Recognizing the anchor by "not user-created" saw only the + * assistant-leaf kind: this one was rebuilt as an ordinary turn, and the sync + * path then merged the identity-only projection over the stored row, blanking + * the prompt the user is still looking at. + */ + it('resumes a compaction anchored on a user leaf', async () => { + const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); + const answer = { + messageId: 'answer', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'The long answer', + isCreatedByUser: false, + } as TMessage; + const userLeaf = buildUserMessage(CONVERSATION_ID, 'user-leaf'); + userLeaf.parentMessageId = answer.messageId; + userLeaf.text = 'Compact this before I continue'; + const observedSubmissions: Array = []; + + mockUseStreamStatus.mockReturnValue({ + isSuccess: true, + isFetching: false, + data: { + active: true, + status: 'running', + streamId: CONVERSATION_ID, + resumeState: { + runSteps: [], + aggregatedContent: [], + replayEvents: [], + responseMessageId: `${userLeaf.messageId}_`, + conversationId: CONVERSATION_ID, + isRegenerate: true, + /** `projectCompactionAnchor`: identity only, whoever wrote the leaf. */ + userMessage: { + messageId: userLeaf.messageId, + conversationId: CONVERSATION_ID, + text: '', + }, + }, + }, + }); + + renderUseResumeOnLoad({ + messages: [rootUser, answer, userLeaf], + onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission), + }); + + await act(async () => { + await Promise.resolve(); + }); + + const submission = observedSubmissions[observedSubmissions.length - 1]; + /** Marked, so no handler writes the anchor as a row... */ + expect(submission?.compact).toBe(true); + /** ...and the leaf keeps the prompt the user actually sent. */ + expect(submission?.userMessage).toEqual( + expect.objectContaining({ + messageId: userLeaf.messageId, + parentMessageId: answer.messageId, + text: 'Compact this before I continue', + isCreatedByUser: true, + }), + ); + expect(submission?.initialResponse?.parentMessageId).toBe(userLeaf.messageId); + expect((submission?.messages ?? []).map((message) => message.messageId)).toEqual([ + rootUser.messageId, + answer.messageId, + userLeaf.messageId, + ]); + }); + + it('restores a compacting branch from the anchor when no response id is published yet', async () => { + const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); + const anchor = { + messageId: 'branch-one-answer', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'Branch one answer', + isCreatedByUser: false, + } as TMessage; + const newerSibling = { + messageId: 'branch-two-answer', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'Branch two answer', + isCreatedByUser: false, + } as TMessage; + const observedSiblingIndexes: number[] = []; + + mockUseStreamStatus.mockReturnValue({ + isSuccess: true, + isFetching: false, + data: { + active: true, + status: 'running', + streamId: CONVERSATION_ID, + resumeState: { + runSteps: [], + aggregatedContent: [], + replayEvents: [], + conversationId: CONVERSATION_ID, + isRegenerate: true, + /** The anchor carries no parent, so it has to name the branch itself. */ + userMessage: { + messageId: anchor.messageId, + conversationId: CONVERSATION_ID, + text: '', + }, + }, + }, + }); + + renderUseResumeOnLoad({ + messages: [rootUser, anchor, newerSibling], + siblingIndexParentId: rootUser.messageId, + onSiblingIndex: (siblingIndex) => observedSiblingIndexes.push(siblingIndex), + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(1); + }); + it('restores the regenerate branch without claiming its older response', async () => { const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); const olderResponse = { diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 91aa6103667..52a5d17d2b9 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -1286,7 +1286,15 @@ export default function useEventHandlers({ submission, error, }); - setMessages([...submission.messages, submission.userMessage, errorResponse]); + /** A compaction has no user row: its `userMessage` slot names the leaf + * the turn hangs off, which `messages` already holds. Writing it here + * would duplicate that id as an empty, self-parented user message — + * a phantom root the thread then folds into. */ + setMessages( + submission.compact === true + ? [...submission.messages, errorResponse] + : [...submission.messages, submission.userMessage, errorResponse], + ); recoverConversation(conversationId || errorResponse.conversationId || v4(), submission); setIsSubmitting(false); } diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index f677914321d..a944aab3b37 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -68,6 +68,7 @@ import { findPendingActionMessageIndex, insertQueuedOrigin, hydrateFileDeliveryMetadata, + isCompactionAnchorProjection, } from '~/utils'; import { useGetUserBalance, @@ -610,12 +611,28 @@ const buildResumeEventSubmission = ( currentUserMessage.conversationId ?? currentSubmission.conversation?.conversationId; - const userMessage = { - ...currentUserMessage, - ...resumeState.userMessage, - conversationId, - isCreatedByUser: true, - } as TMessage; + /** + * A compaction submits no user turn: its user-message slot names the LEAF it + * summarizes up to, and the server projects that anchor as identity only + * (`projectCompactionAnchor` — no parent, no author). Adopting the projection + * as a user row would rewrite the leaf into an empty, parentless message, so + * an anchored run keeps the identity it resumed with. + */ + /** Read from the projection as well as the flag: a re-attach whose submission + * never learned it was a compaction still has to recognize the anchor. */ + const anchoredUserMessage = + currentSubmission.compact === true || isCompactionAnchorProjection(resumeState.userMessage); + + const userMessage = ( + anchoredUserMessage + ? { ...currentUserMessage, conversationId } + : { + ...currentUserMessage, + ...resumeState.userMessage, + conversationId, + isCreatedByUser: true, + } + ) as TMessage; const responseMessageId = resumeState.responseMessageId ?? @@ -644,6 +661,9 @@ const buildResumeEventSubmission = ( }, userMessage, initialResponse, + /** Carried so every consumer of the resumed submission — the merges below, + * the abort-error write — reads the same answer this resolved. */ + ...(anchoredUserMessage && { compact: true }), } as EventSubmission; }; @@ -707,12 +727,20 @@ const mergeResumeMessages = ( userMessage: TMessage, responseMessage: TMessage, indexes: ResumeMessageIndexes, + /** + * An anchored run — a compaction — owns no user row. Its user-message slot + * holds the leaf the summary hangs off, a row the transcript already has: + * merging the slot onto it would replace that answer with an empty user + * message, and inserting it would duplicate its id. Either way the row loses + * its parent and `buildTree` files it as a phantom root, folding the thread. + */ + anchoredUserMessage = false, ): TMessage[] => { const nextMessages = [...messages]; let { userIndex, responseIndex, preliminaryResponseIndex } = indexes; const { preliminaryUserIndex } = indexes; - if (preliminaryUserIndex >= 0) { + if (preliminaryUserIndex >= 0 && !anchoredUserMessage) { if (userIndex >= 0) { nextMessages.splice(preliminaryUserIndex, 1); if (userIndex > preliminaryUserIndex) { @@ -751,7 +779,7 @@ const mergeResumeMessages = ( } } - if (userIndex >= 0) { + if (userIndex >= 0 && !anchoredUserMessage) { nextMessages[userIndex] = { ...nextMessages[userIndex], ...userMessage }; } @@ -759,6 +787,10 @@ const mergeResumeMessages = ( nextMessages[responseIndex] = { ...nextMessages[responseIndex], ...responseMessage }; } + if (anchoredUserMessage) { + return responseIndex >= 0 ? nextMessages : [...nextMessages, responseMessage]; + } + if (userIndex >= 0 && responseIndex >= 0) { return nextMessages; } @@ -2373,6 +2405,7 @@ export default function useResumableSSE( userMessage, responseMessage, messageIndexes, + resumeSubmission.compact === true, ); logger.log('ResumableSSE', 'SYNC updating message', { messageId: responseMessage.messageId, @@ -2399,7 +2432,15 @@ export default function useResumableSSE( content: data.resumeState.aggregatedContent, isCreatedByUser: false, } as TMessage; - setMessages(mergeResumeMessages(messages, userMessage, newMessage, messageIndexes)); + setMessages( + mergeResumeMessages( + messages, + userMessage, + newMessage, + messageIndexes, + resumeSubmission.compact === true, + ), + ); resetContentHandler(); syncStepMessage(newMessage); } diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts index 68e9bb60551..4dc45d8bcf7 100644 --- a/client/src/hooks/SSE/useResumeOnLoad.ts +++ b/client/src/hooks/SSE/useResumeOnLoad.ts @@ -21,6 +21,7 @@ import { carriedSteerContext, getBranchSiblingIndexesForTarget, hydrateFileDeliveryMetadata, + isCompactionAnchorProjection, } from '~/utils'; import { useStreamStatus, @@ -85,13 +86,27 @@ function resumeStateMatchesSubmission( return !!responseMessageId && resumeState.responseMessageId === responseMessageId; } +/** + * The row that names the branch a resumed run belongs to when its own response + * is not in the loaded history yet. A compaction's user-message slot is the leaf + * it summarizes up to and carries no parent (`projectCompactionAnchor`), so + * there the anchor itself names the branch — without this the pane restores no + * sibling selection and a compaction started on an older branch comes back on + * whichever branch the default lands on. + */ +function getResumeBranchFallbackMessageId( + resumeState: Agents.ResumeState, +): string | null | undefined { + return resumeState.userMessage?.parentMessageId ?? resumeState.userMessage?.messageId; +} + function getResumeBranchTargetMessageId( resumeState: Agents.ResumeState, messages: TMessage[], ): string | null | undefined { const responseMessageId = resumeState.responseMessageId; if (!responseMessageId) { - return resumeState.userMessage?.parentMessageId; + return getResumeBranchFallbackMessageId(resumeState); } const unpaddedResponseMessageId = responseMessageId.replace(/_+$/, ''); @@ -117,7 +132,7 @@ function getResumeBranchTargetMessageId( return unpaddedResponseMessageId; } - return resumeState.userMessage?.parentMessageId; + return getResumeBranchFallbackMessageId(resumeState); } function preferDefinedString(value?: string | null, fallback?: string): string | undefined { @@ -140,10 +155,22 @@ function buildSubmissionFromResumeState( const responseMessageId = resumeState.responseMessageId ?? `${userMessageData?.messageId ?? 'resume'}_`; - // Try to find existing user message in the messages array (from database) - const existingUserMessage = messages.find( - (m) => m.isCreatedByUser && m.messageId === userMessageData?.messageId, - ); + /** + * The run's user-message slot as the loaded history already holds it. Message + * ids are unique per row, so a match is that row whoever wrote it: a + * compaction submits no user turn and puts the LEAF it summarizes up to in + * this slot (`useCompactConversation` client-side, `projectCompactionAnchor` + * server-side), which is usually the assistant answer. Adopting the row keeps + * the anchor's own identity — synthesizing an empty, parentless USER row over + * it rewrites that answer into a phantom root and folds the thread. + */ + const existingSlotMessage = messages.find((m) => m.messageId === userMessageData?.messageId); + /** An anchored run — a compaction — created no user turn: the slot names a row + * the transcript already holds. Judged from the projection's shape, never from + * the anchor's author, which is a user message as often as an answer. Either + * way the run is regenerate-shaped for every consumer: no user turn of its + * own, the response parented onto an existing message. */ + const isAnchoredRun = isCompactionAnchorProjection(userMessageData); // A trailing underscore distinguishes an in-flight regeneration from the persisted // response it replaces. Only the exact response id proves generation ownership. @@ -158,7 +185,7 @@ function buildSubmissionFromResumeState( : undefined; const responseMetadataMessage = existingResponseMessage ?? persistedRegenerationResponse; const isRegenerateResume = - resumeState.isRegenerate === true || persistedRegenerationResponse != null; + resumeState.isRegenerate === true || persistedRegenerationResponse != null || isAnchoredRun; let regenerateMessages: TMessage[] | undefined; if (isRegenerateResume) { regenerateMessages = @@ -167,9 +194,9 @@ function buildSubmissionFromResumeState( : messages.filter((message) => message.messageId !== responseMessageId); } - // Create or use existing user message + // Create or use the row the slot already names const userMessage: TMessage = - existingUserMessage ?? + existingSlotMessage ?? (userMessageData ? (tMessageSchema.parse({ messageId: userMessageData.messageId, @@ -230,6 +257,7 @@ function buildSubmissionFromResumeState( initialResponse, conversation, isRegenerate: isRegenerateResume, + ...(isAnchoredRun && { compact: true }), ...(regenerateMessages && { regenerateMessages }), isTemporary: false, endpointOption: {}, diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index f279360eb79..02e05d5f74e 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -90,6 +90,34 @@ export const getMessageBranchSiblingParentIds = ( return Array.from(parentIds); }; +/** + * True when a resumed run's user-message slot is a compaction ANCHOR rather than + * a turn the run created. + * + * A compaction submits no user turn: the slot names the LEAF it summarizes up + * to, which the server projects as identity only (`projectCompactionAnchor` — + * id, conversation, empty text, and no parent). Every other run publishes the + * turn it created, parent included (`getPreliminaryUserMessage`), so the absent + * parent is what separates the two. + * + * The anchor's author cannot separate them: Compact runs on whatever leaf the + * branch ends with and `canCompact` does not restrict its author, so the anchor + * is an assistant answer on one branch and a user message on the next (see + * `isUserInitiatedCompaction`). Testing for a non-user row recognized only the + * first kind and left the second rewritten on every re-attach. + */ +export const isCompactionAnchorProjection = ( + userMessage?: { + messageId?: string; + parentMessageId?: string | null; + text?: string | null; + } | null, +): boolean => + userMessage?.messageId != null && + userMessage.messageId !== '' && + userMessage.parentMessageId == null && + (userMessage.text == null || userMessage.text === ''); + export const getBranchSiblingIndexesForTarget = ( messages: TMessage[] | null | undefined, targetMessageId: string | null | undefined, diff --git a/e2e/specs/mock/thread-fold.spec.ts b/e2e/specs/mock/thread-fold.spec.ts index 7db9f293d34..c25bd8702d1 100644 --- a/e2e/specs/mock/thread-fold.spec.ts +++ b/e2e/specs/mock/thread-fold.spec.ts @@ -24,6 +24,10 @@ import { const uniqueLabel = (prefix: string) => `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; +/** The summarizer the mock deployment's endpoints call; `delayMs` holds a + * compaction open long enough to reload into it. */ +const LABEL_SERVER = `http://127.0.0.1:${process.env.E2E_LABEL_PORT || '8889'}`; + const countedPrompt = (label: string) => `E2E_COUNTED_REPLY:${label}`; const countedReplyText = (label: string, count: number) => `E2E counted reply ${label} #${count}`; @@ -61,8 +65,12 @@ async function clickSibling(page: Page, messageTextValue: string, direction: 'Pr } test.describe('thread fold regressions', () => { - test.afterEach(async ({ page }) => { + test.afterEach(async ({ page, request }) => { await page.evaluate(() => window.localStorage.removeItem('steerInterruptsByDefault')); + /** The summarizer fixture is shared: a test that slowed it must hand it + * back whether it passed, failed, or was retried. */ + const response = await request.post(`${LABEL_SERVER}/__e2e/reset`); + expect(response.ok()).toBeTruthy(); }); test('thread survives a mid-stream interrupt and matches its own post-reload rendering', async ({ @@ -181,4 +189,62 @@ test.describe('thread fold regressions', () => { await expect(messagesView(page).getByText(followReply)).toBeHidden(); await expect(siblingCounter(page)).toHaveText('2 / 2'); }); + + /** + * A manual compaction submits no user turn: it hangs a summarize-only response + * off the branch's leaf and puts that leaf in the submission's user-message + * slot (the server projects it the same way, identity only). Re-attaching to + * one — a reload, a navigation back, a dropped connection — used to adopt that + * projection as a ROW, rewriting the answer being summarized into an empty, + * parentless user message. The message tree files a parentless row as a root, + * so the whole thread above the compaction dropped out of the visible branch. + */ + test('a compaction resumed after a reload keeps the thread it summarizes', async ({ + page, + request, + }) => { + test.setTimeout(180000); + const label = uniqueLabel('fold-compact'); + const setupPrompt = replyPrompt(label); + const setupReply = replyText(label); + + await openMockChat(page); + await sendAndExpectReply(page, setupPrompt, setupReply); + await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}$/, { timeout: 15000 }); + + /** Hold the summarizer so the compaction is still running when the page + * comes back and the pane resumes it. */ + const behavior = await request.post(`${LABEL_SERVER}/__e2e/behavior`, { + data: { delayMs: 15000 }, + }); + expect(behavior.ok()).toBeTruthy(); + + await page.getByTestId('token-usage').click(); + await page.getByRole('button', { name: 'Compact context' }).click(); + await expect(page.getByRole('button', { name: 'Stop generating' })).toBeVisible({ + timeout: 15000, + }); + + await page.reload({ timeout: 15000 }); + + /** The turn under compaction is still the answer it always was, on the one + * branch the conversation has — a rewritten anchor would strand both rows + * behind a phantom root and offer a sibling switcher to page back to them. */ + await expect(messagesView(page).getByText(setupPrompt)).toBeVisible({ timeout: 30000 }); + await expect(messagesView(page).getByText(setupReply)).toBeVisible(); + await expect(page.getByRole('navigation', { name: 'Sibling message navigation' })).toHaveCount( + 0, + ); + + /** The summary then settles under that answer, thread intact. */ + await expect(messagesView(page).getByText('You compacted the context')).toBeVisible({ + timeout: 60000, + }); + await expect(messagesView(page).getByText(setupPrompt)).toBeVisible(); + await expect(messagesView(page).getByText(setupReply)).toBeVisible(); + await expect(messageTurns(page)).toHaveCount(3); + await expect(page.getByRole('navigation', { name: 'Sibling message navigation' })).toHaveCount( + 0, + ); + }); });