Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ const CONV_ID = 'conv-abc-123';
type PartialSubmission = {
conversation: { conversationId?: string };
isRegenerate?: boolean;
compact?: boolean;
editedContent?: Record<string, unknown>;
editPrefixLength?: number;
clientRequestId?: string;
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading