mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* 🌿 fix: Preserve Viewed Branch on Sibling-Tree Churn Regenerating a message could snap the view to an unrelated newest branch. MultiMessage reset siblingIdx to 0 (newest) on any messagesTree.length change, but getRegenerateSubmissionMessages slices the flat message array during a regenerate — the streaming handlers render a tree missing unrelated sibling branches, then finalHandler restores the full set. That 2→1→2 child-count swing snapped unrelated forks to their newest sibling, so regenerating the latest response on an older branch jumped to a previously regenerated branch. Replace the indiscriminate reset with per-fork branch memory: a 'seen' set distinguishes a genuinely new sibling (submission/regeneration/edit here — focus it) from one transiently dropped and restored (preserve the user's branch). Decision extracted as the pure, unit-tested resolveSiblingSelection. - client/src/utils/messages.ts: resolveSiblingSelection + tests - MultiMessage: seen/selectedId refs, structural id-signature effect - e2e: regenerate-latest-on-older-branch keeps the viewed branch (fails on the old reset, passes now) * 🧪 test: Long-Thread Branch Preservation E2E Add the user-reported scenario: in a multi-turn thread, regenerate an earlier response (forking a root branch), switch back to the original, then regenerate a later response on it — the original branch must stay intact. Uses labeled prompts so each turn's unique reply is a reliable settle signal. Verified it fails on the original MultiMessage and passes with the fix. * 🎨 style: Fix import order in MultiMessage (react before recoil) * 🌿 fix: Keep Unrelated Branches in Regenerate Optimistic Render Regenerating a message used a flat `messages.slice(0, targetIndex)` for the optimistic render, which also drops unrelated sibling branches that merely sit later in the flat array. Mid-regenerate the thread briefly collapsed to a short branch (visible flash) and the scroll jumped to the shrunken content and didn't recover — the same flat-array root cause as the branch-reset bug. Remove only the regenerated response and its descendants, keeping unrelated branches. The thread (and scroll) stay put through the regenerate. This array is render-only — the server regenerates from parentMessageId and createPayload doesn't include it — so summing by subtree never affects the request. Verified via a small-viewport scroll trace: old collapses 903->295px / 8->2 renders mid-stream; fixed stays 903px / 8 renders, scroll held at bottom. Unit test covers the keep-unrelated-branches behavior (fails on the old slice). * 🌿 fix: Let an Explicit Branch Selection Survive Streaming ID Churn resolveSiblingSelection focused any unseen sibling id before checking the committed selection. When an in-flight response's id is replaced mid-stream (placeholder → server/run id, e.g. useStepHandler re-keys to runId) after the user switched to a different sibling, that swap looked like a brand-new sibling and stole focus back to the streaming branch. Reorder: the committed selection wins while still present; only focus a fresh sibling when the selection is gone (regenerated away, or its own placeholder id was just replaced — that's how a regen/edit still takes focus, since the slice removes the old response). Added unit tests for both churn directions. * 🌿 fix: Only Focus a New Sibling When the Fork Actually Grew The previous churn fix (selection-wins-first) was too aggressive: a genuinely new sibling ADDED while the prior selection is still present — e.g. a follow-up re-parented as a sibling after a generation-start failure — was no longer focused, so its reply never rendered (broke message-tree generation-start recovery e2e). Gate new-sibling focus on actual growth: resolveSiblingSelection now takes prevCount and only focuses a never-seen id when ids.length > prevCount. A same-count placeholder→server id swap (churn) or a restored already-seen sibling is not growth, so the committed selection still wins there. Covers follow-up/new-branch focus, churn steal-prevention, and self-churn follow. message-tree + chat e2e: 17 passed (incl. the recovered generation-start test). * 🌿 refactor: Drop MultiMessage Branch-Memory in Favor of the Slice Fix The regenerate-slice fix (keep unrelated branches in the optimistic render) is the true root cause: with no spurious tree collapse, the original setSiblingIdx(0)-on-length-change never misfires, so the branch-reset is fixed without per-fork memory. The earlier MultiMessage rewrite (seen/selectedId/ prevCount + resolveSiblingSelection) was a symptom patch added before the root cause was found, and its per-instance memory generated two edge-case findings (placeholder→server id churn; divergence from external siblingIdx writes like resume restore). Revert MultiMessage to the simple upstream version and remove resolveSiblingSelection (+ its tests). The slice fix + the existing branch e2e (chat.spec: switch-back, regenerate-latest, long-thread) cover the behavior; all 17 chat + message-tree branch specs pass with this version. * 🌿 fix: Focus the Regenerated Response When Its Fork Count Is Unchanged When a parent already has multiple sibling responses and the user switches to a non-latest one and regenerates it, the optimistic slice drops the target but keeps the other siblings, so the child count is unchanged. MultiMessage only resets the (reversed) sibling index on a length change, so the stale index kept pointing at the kept sibling and the regenerating response stayed hidden until the server restored the dropped sibling at finalize (count bump → reset). Explicitly focus the newest sibling (reversed index 0 = the appended response) of the regenerated fork in createdHandler. Position-based, fires only on the regenerate action, so it doesn't reintroduce the placeholder→server id churn or external-write fragility that a per-render selection memory had. E2E: new during-stream test (slow+counted reply marker) asserting the regenerating response is visible before finalize; negatively verified (fails without the focus call, passes with it). * 🌿 fix: Eliminate Pre-Created Flash by Focusing at the Optimistic Render The createdHandler focus removed the until-finalize bug, but a brief flash remained between clicking regenerate and the `created` event: useChatFunctions renders the optimistic placeholder first, and that render has the same unchanged-count problem, so the kept sibling showed until createdHandler fired. Extract the focus into a shared useFocusRegeneratedResponse hook and apply it at the optimistic render too (useChatFunctions) and on `created` (useEventHandlers). The placeholder is now focused from the first frame. E2E: gated pre-created test — holds the SSE stream GET (the chat POST returns a stream id; the stream is a separate GET) so `created` cannot arrive, leaving only the optimistic render, then asserts the kept sibling is already gone. This isolates the optimistic focus (createdHandler cannot mask it); negatively verified (fails without the optimistic focus call). * 🧪 test: Extend Store Mock for the Regenerate Focus Hook useChatFunctions.regenerate.spec.tsx mocks ~/store and recoil partially; the new useFocusRegeneratedResponse calls store.messagesSiblingIdxFamily via a recoil `set`, neither of which the mock provided (TypeError on regenerate). Add messagesSiblingIdxFamily to the store mock and `set` to the useRecoilCallback mock. Test-only; production code unchanged.
1248 lines
43 KiB
TypeScript
1248 lines
43 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Page, Response, Route } from '@playwright/test';
|
|
import {
|
|
isAgentGenerationStart,
|
|
MOCK_ENDPOINTS,
|
|
NEW_CHAT_PATH,
|
|
fetchJson,
|
|
getAccessToken,
|
|
selectMockEndpoint,
|
|
sendMessage,
|
|
} from './helpers';
|
|
|
|
const NO_PARENT = '00000000-0000-0000-0000-000000000000';
|
|
|
|
type TextContentPart = {
|
|
type?: string;
|
|
text?: string | { value?: string };
|
|
error?: string;
|
|
};
|
|
|
|
type E2EMessage = {
|
|
messageId: string;
|
|
parentMessageId?: string | null;
|
|
conversationId?: string | null;
|
|
text?: string;
|
|
content?: TextContentPart[];
|
|
isCreatedByUser?: boolean;
|
|
error?: boolean;
|
|
unfinished?: boolean;
|
|
};
|
|
|
|
type ForkResponse = {
|
|
conversation: {
|
|
conversationId?: string;
|
|
};
|
|
messages: E2EMessage[];
|
|
};
|
|
|
|
type JsonResponse = {
|
|
ok: boolean;
|
|
status: number;
|
|
text: string;
|
|
json: unknown;
|
|
};
|
|
|
|
const uniqueLabel = (name: string) => `${name}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
|
|
const replyPrompt = (label: string) => `E2E_REPLY:${label}`;
|
|
const replyText = (label: string) => `E2E reply ${label}`;
|
|
const countedPrompt = (label: string) => `E2E_COUNTED_REPLY:${label}`;
|
|
const countedReplyText = (label: string, count: number) => `E2E counted reply ${label} #${count}`;
|
|
const slowPrompt = (label: string) => `E2E_SLOW_REPLY:${label}`;
|
|
const slowReplyPrefix = (label: string) => `E2E slow reply ${label}`;
|
|
const slowCountedPrompt = (label: string) => `E2E_SLOW_COUNTED_REPLY:${label}`;
|
|
const slowCountedReplyText = (label: string, count: number) =>
|
|
`E2E slow counted reply ${label} #${count}`;
|
|
|
|
const messagesView = (page: Page) => page.getByTestId('messages-view');
|
|
const messageRender = (page: Page, text: string) =>
|
|
page.locator('.message-render').filter({ hasText: text }).last();
|
|
const conversationPath = (conversationId: string) => `/c/${encodeURIComponent(conversationId)}`;
|
|
|
|
function contentText(part: TextContentPart): string {
|
|
if (typeof part.text === 'string') {
|
|
return part.text;
|
|
}
|
|
if (part.text?.value) {
|
|
return part.text.value;
|
|
}
|
|
return part.error ?? '';
|
|
}
|
|
|
|
function messageText(message: E2EMessage): string {
|
|
if (message.text) {
|
|
return message.text;
|
|
}
|
|
return message.content?.map(contentText).filter(Boolean).join('\n') ?? '';
|
|
}
|
|
|
|
function sseMessage(payload: unknown): string {
|
|
return `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
}
|
|
|
|
function findMessage(messages: E2EMessage[], text: string, isCreatedByUser?: boolean): E2EMessage {
|
|
const message = messages.find((candidate) => {
|
|
const roleMatches =
|
|
isCreatedByUser === undefined || candidate.isCreatedByUser === isCreatedByUser;
|
|
return roleMatches && messageText(candidate).includes(text);
|
|
});
|
|
if (!message) {
|
|
throw new Error(
|
|
`Expected message containing "${text}". Saw:\n${messages.map(messageText).join('\n---\n')}`,
|
|
);
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function expectParent(
|
|
messages: E2EMessage[],
|
|
childText: string,
|
|
parentText: string,
|
|
childIsUser?: boolean,
|
|
) {
|
|
const child = findMessage(messages, childText, childIsUser);
|
|
const parent = findMessage(messages, parentText);
|
|
expect(child.parentMessageId, `${childText} should be a child of ${parentText}`).toBe(
|
|
parent.messageId,
|
|
);
|
|
}
|
|
|
|
function expectNoFoldedMessages(messages: E2EMessage[]) {
|
|
const ids = new Set(messages.map((message) => message.messageId));
|
|
const folded = messages.filter((message) => {
|
|
const parentId = message.parentMessageId;
|
|
return parentId != null && parentId !== '' && parentId !== NO_PARENT && !ids.has(parentId);
|
|
});
|
|
expect(
|
|
folded.map((message) => ({
|
|
text: messageText(message),
|
|
messageId: message.messageId,
|
|
parentMessageId: message.parentMessageId,
|
|
})),
|
|
'messages must not render as parent-less folded children',
|
|
).toEqual([]);
|
|
|
|
const roots = messages.filter((message) => {
|
|
const parentId = message.parentMessageId;
|
|
return parentId == null || parentId === '' || parentId === NO_PARENT;
|
|
});
|
|
expect(
|
|
roots.map((message) => ({
|
|
text: messageText(message),
|
|
isCreatedByUser: message.isCreatedByUser,
|
|
})),
|
|
'only user messages should be roots',
|
|
).toEqual(roots.map(() => expect.objectContaining({ isCreatedByUser: true })));
|
|
}
|
|
|
|
async function expectVisibleMessages(page: Page, texts: string[]) {
|
|
for (const text of texts) {
|
|
await expect(messagesView(page).getByText(text)).toBeVisible({ timeout: 30000 });
|
|
}
|
|
}
|
|
|
|
async function reloadAndExpectMessages(page: Page, texts: string[]) {
|
|
await page.reload({ timeout: 10000 });
|
|
await expectVisibleMessages(page, texts);
|
|
}
|
|
|
|
async function revisitConversationAndExpectMessages(
|
|
page: Page,
|
|
conversationId: string,
|
|
texts: string[],
|
|
) {
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await page.goto(conversationPath(conversationId), { timeout: 10000 });
|
|
await expectVisibleMessages(page, texts);
|
|
}
|
|
|
|
async function mockActiveOAuthResumeStream({
|
|
page,
|
|
authUrl,
|
|
conversationId,
|
|
parentMessageId,
|
|
pendingPrompt,
|
|
pendingUserMessageId,
|
|
postAuthRunId,
|
|
postAuthText,
|
|
}: {
|
|
page: Page;
|
|
authUrl: string;
|
|
conversationId: string;
|
|
parentMessageId: string;
|
|
pendingPrompt: string;
|
|
pendingUserMessageId: string;
|
|
postAuthRunId?: string;
|
|
postAuthText?: string;
|
|
}) {
|
|
const pendingResponseMessageId = `${pendingUserMessageId}_`;
|
|
const toolCallId = `${pendingUserMessageId}:Google-Workspace`;
|
|
const stepId = 'step_oauth_login_Google-Workspace';
|
|
const messageStepId = 'step_post_auth_message';
|
|
const resumeState = {
|
|
runSteps: [],
|
|
aggregatedContent: [],
|
|
responseMessageId: pendingResponseMessageId,
|
|
conversationId,
|
|
userMessage: {
|
|
messageId: pendingUserMessageId,
|
|
parentMessageId,
|
|
conversationId,
|
|
text: pendingPrompt,
|
|
},
|
|
replayEvents: [
|
|
{
|
|
event: 'on_run_step',
|
|
data: {
|
|
runId: 'USE_PRELIM_RESPONSE_MESSAGE_ID',
|
|
id: stepId,
|
|
type: 'tool_calls',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [
|
|
{
|
|
id: toolCallId,
|
|
name: 'oauth_mcp_Google-Workspace',
|
|
type: 'tool_call_chunk',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: 'on_run_step_delta',
|
|
data: {
|
|
id: stepId,
|
|
delta: {
|
|
type: 'tool_calls',
|
|
tool_calls: [
|
|
{
|
|
id: toolCallId,
|
|
name: 'oauth_mcp_Google-Workspace',
|
|
type: 'tool_call_chunk',
|
|
args: '',
|
|
},
|
|
],
|
|
auth: authUrl,
|
|
expires_at: Date.now() + 120000,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const streamPayloads: unknown[] = [
|
|
{
|
|
sync: true,
|
|
resumeState,
|
|
pendingEvents: [],
|
|
},
|
|
];
|
|
|
|
if (postAuthText) {
|
|
streamPayloads.push(
|
|
{
|
|
event: 'on_run_step',
|
|
data: {
|
|
runId: postAuthRunId ?? 'USE_PRELIM_RESPONSE_MESSAGE_ID',
|
|
id: messageStepId,
|
|
type: 'message_creation',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'message_creation',
|
|
message_creation: {
|
|
message_id: `${pendingResponseMessageId}-post-auth`,
|
|
},
|
|
},
|
|
usage: null,
|
|
},
|
|
},
|
|
{
|
|
event: 'on_message_delta',
|
|
data: {
|
|
id: messageStepId,
|
|
delta: {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: postAuthText,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
await page.route(`**/api/agents/chat/status/${conversationId}`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
active: true,
|
|
streamId: conversationId,
|
|
status: 'running',
|
|
aggregatedContent: [],
|
|
createdAt: Date.now(),
|
|
resumeState,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route(`**/api/agents/chat/stream/${conversationId}**`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: streamPayloads.map(sseMessage).join(''),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function mockPreCreatedOAuthStream({
|
|
page,
|
|
authUrl,
|
|
conversationId,
|
|
parentMessageId,
|
|
prompt,
|
|
serverUserMessageId,
|
|
responseMessageId,
|
|
postAuthText,
|
|
}: {
|
|
page: Page;
|
|
authUrl: string;
|
|
conversationId: string;
|
|
parentMessageId: string;
|
|
prompt: string;
|
|
serverUserMessageId: string;
|
|
responseMessageId: string;
|
|
postAuthText: string;
|
|
}) {
|
|
const toolCallId = `${serverUserMessageId}:Google-Workspace`;
|
|
const oauthStepId = 'step_oauth_login_Google-Workspace';
|
|
const reasoningStepId = 'step_post_auth_reasoning';
|
|
const messageStepId = 'step_post_auth_message';
|
|
const payloads = [
|
|
{
|
|
event: 'on_run_step',
|
|
data: {
|
|
runId: 'USE_PRELIM_RESPONSE_MESSAGE_ID',
|
|
id: oauthStepId,
|
|
type: 'tool_calls',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [
|
|
{
|
|
id: toolCallId,
|
|
name: 'oauth_mcp_Google-Workspace',
|
|
type: 'tool_call_chunk',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: 'on_run_step_delta',
|
|
data: {
|
|
id: oauthStepId,
|
|
delta: {
|
|
type: 'tool_calls',
|
|
tool_calls: [
|
|
{
|
|
id: toolCallId,
|
|
name: 'oauth_mcp_Google-Workspace',
|
|
type: 'tool_call_chunk',
|
|
args: '',
|
|
},
|
|
],
|
|
auth: authUrl,
|
|
expires_at: Date.now() + 120000,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: 'on_run_step_completed',
|
|
data: {
|
|
result: {
|
|
id: oauthStepId,
|
|
index: 0,
|
|
tool_call: {
|
|
id: toolCallId,
|
|
name: 'oauth_mcp_Google-Workspace',
|
|
args: '',
|
|
output: 'OAuth authentication completed',
|
|
type: 'tool_call',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
created: true,
|
|
message: {
|
|
messageId: serverUserMessageId,
|
|
parentMessageId,
|
|
conversationId,
|
|
sender: 'User',
|
|
text: prompt,
|
|
isCreatedByUser: true,
|
|
},
|
|
streamId: conversationId,
|
|
},
|
|
{
|
|
event: 'on_run_step',
|
|
data: {
|
|
runId: responseMessageId,
|
|
id: reasoningStepId,
|
|
type: 'message_creation',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'message_creation',
|
|
message_creation: {
|
|
message_id: `${responseMessageId}-reasoning`,
|
|
},
|
|
},
|
|
usage: null,
|
|
},
|
|
},
|
|
{
|
|
event: 'on_reasoning_delta',
|
|
data: {
|
|
id: reasoningStepId,
|
|
delta: {
|
|
content: [
|
|
{
|
|
type: 'think',
|
|
think: 'The user completed OAuth.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: 'on_run_step',
|
|
data: {
|
|
runId: responseMessageId,
|
|
id: messageStepId,
|
|
type: 'message_creation',
|
|
index: 1,
|
|
stepDetails: {
|
|
type: 'message_creation',
|
|
message_creation: {
|
|
message_id: `${responseMessageId}-message`,
|
|
},
|
|
},
|
|
usage: null,
|
|
},
|
|
},
|
|
{
|
|
event: 'on_message_delta',
|
|
data: {
|
|
id: messageStepId,
|
|
delta: {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: postAuthText,
|
|
index: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
await page.route(`**/api/agents/chat/stream/${conversationId}**`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: payloads.map(sseMessage).join(''),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function openMockChat(page: Page) {
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
}
|
|
|
|
function isAgentGenerationResponse(response: Response, expectedStatus: number) {
|
|
const { pathname } = new URL(response.url());
|
|
const isAgentsChat = pathname === '/api/agents/chat' || pathname.startsWith('/api/agents/chat/');
|
|
return (
|
|
response.request().method() === 'POST' &&
|
|
isAgentsChat &&
|
|
!pathname.endsWith('/abort') &&
|
|
response.status() === expectedStatus
|
|
);
|
|
}
|
|
|
|
async function waitForGenerationStart(page: Page, action: () => Promise<void>): Promise<Response> {
|
|
const [response] = await Promise.all([
|
|
page.waitForResponse(isAgentGenerationStart, { timeout: 30000 }),
|
|
action(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
return response;
|
|
}
|
|
|
|
async function sendAndExpectReply(page: Page, prompt: string, expectedReply: string) {
|
|
const response = await sendMessage(page, prompt);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(messagesView(page).getByText(expectedReply)).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
async function submitMessageExpectingGenerationFailure(
|
|
page: Page,
|
|
prompt: string,
|
|
expectedStatus: number,
|
|
) {
|
|
const input = page.getByRole('textbox', { name: 'Message input' });
|
|
await expect(input).toBeEnabled({ timeout: 30000 });
|
|
await input.click();
|
|
await input.fill(prompt);
|
|
const [response] = await Promise.all([
|
|
page.waitForResponse((res) => isAgentGenerationResponse(res, expectedStatus), {
|
|
timeout: 30000,
|
|
}),
|
|
input.press('Enter'),
|
|
]);
|
|
return response;
|
|
}
|
|
|
|
async function conversationIdFromPage(page: Page): Promise<string> {
|
|
await expect(page).toHaveURL(/\/c\/(?!new)[0-9a-fA-F-]{36}$/);
|
|
const id = new URL(page.url()).pathname.split('/').pop();
|
|
if (!id) {
|
|
throw new Error(`Could not parse conversation id from ${page.url()}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function fetchMessages(
|
|
page: Page,
|
|
conversationId: string,
|
|
accessToken?: string,
|
|
): Promise<E2EMessage[]> {
|
|
const token = accessToken ?? (await getAccessToken(page));
|
|
return fetchJson<E2EMessage[]>(
|
|
page,
|
|
`/api/messages/${encodeURIComponent(conversationId)}`,
|
|
token,
|
|
);
|
|
}
|
|
|
|
async function postJsonWithStatus(page: Page, path: string, token: string, body: unknown) {
|
|
return page.evaluate(
|
|
async ({ accessToken, requestBody, urlPath }): Promise<JsonResponse> => {
|
|
const response = await fetch(urlPath, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
const text = await response.text();
|
|
let json: unknown = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
return { ok: response.ok, status: response.status, text, json };
|
|
},
|
|
{ accessToken: token, requestBody: body, urlPath: path },
|
|
);
|
|
}
|
|
|
|
async function waitForMessages(
|
|
page: Page,
|
|
conversationId: string,
|
|
predicate: (messages: E2EMessage[]) => boolean,
|
|
description: string,
|
|
): Promise<E2EMessage[]> {
|
|
let latest: E2EMessage[] = [];
|
|
const token = await getAccessToken(page);
|
|
for (let attempt = 0; attempt < 80; attempt++) {
|
|
latest = await fetchMessages(page, conversationId, token);
|
|
if (predicate(latest)) {
|
|
return latest;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for ${description}. Latest messages:\n${latest
|
|
.map(
|
|
(message) => `${message.messageId} <- ${message.parentMessageId}: ${messageText(message)}`,
|
|
)
|
|
.join('\n')}`,
|
|
);
|
|
}
|
|
|
|
async function clickMessageTitleButton(page: Page, messageTextValue: string, title: string) {
|
|
const render = messageRender(page, messageTextValue);
|
|
await render.scrollIntoViewIfNeeded();
|
|
await render.hover();
|
|
await render.locator(`button[title="${title}"]`).last().click();
|
|
}
|
|
|
|
async function clickSibling(page: Page, messageTextValue: string, direction: 'Previous' | 'Next') {
|
|
const render = messageRender(page, messageTextValue);
|
|
await render.scrollIntoViewIfNeeded();
|
|
await render.hover();
|
|
await render.getByRole('button', { name: `${direction} sibling message` }).click();
|
|
}
|
|
|
|
async function expectCanCycleSiblingTexts(page: Page, previousText: string, nextText: string) {
|
|
const previous = messagesView(page).getByText(previousText);
|
|
const next = messagesView(page).getByText(nextText);
|
|
if (await previous.isVisible()) {
|
|
await clickSibling(page, previousText, 'Next');
|
|
await expect(next).toBeVisible();
|
|
await clickSibling(page, nextText, 'Previous');
|
|
await expect(previous).toBeVisible();
|
|
return;
|
|
}
|
|
|
|
if (await next.isVisible()) {
|
|
await clickSibling(page, nextText, 'Previous');
|
|
await expect(previous).toBeVisible();
|
|
await clickSibling(page, previousText, 'Next');
|
|
await expect(next).toBeVisible();
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Expected either sibling "${previousText}" or "${nextText}" to be visible`);
|
|
}
|
|
|
|
async function clickForkVisibleMessages(
|
|
page: Page,
|
|
messageTextValue: string,
|
|
): Promise<ForkResponse> {
|
|
const render = messageRender(page, messageTextValue);
|
|
await render.scrollIntoViewIfNeeded();
|
|
await render.hover();
|
|
await render.getByRole('button', { name: 'Open Fork Menu' }).click();
|
|
|
|
const [response] = await Promise.all([
|
|
page.waitForResponse(
|
|
(res) =>
|
|
res.request().method() === 'POST' &&
|
|
res.url().includes('/api/convos/fork') &&
|
|
res.status() === 200,
|
|
{ timeout: 30000 },
|
|
),
|
|
page.getByRole('button', { name: 'Visible messages only', exact: true }).click(),
|
|
]);
|
|
|
|
return (await response.json()) as ForkResponse;
|
|
}
|
|
|
|
test.describe('message tree stream operations', () => {
|
|
test.setTimeout(180000);
|
|
|
|
test('streams follow-ups and keeps an aborted response as the next parent', async ({ page }) => {
|
|
const label = uniqueLabel('abort');
|
|
const firstPrompt = replyPrompt(`${label}-first`);
|
|
const firstReply = replyText(`${label}-first`);
|
|
const secondPrompt = replyPrompt(`${label}-second`);
|
|
const secondReply = replyText(`${label}-second`);
|
|
const abortPrompt = slowPrompt(`${label}-stop`);
|
|
const abortReply = slowReplyPrefix(`${label}-stop`);
|
|
const afterAbortPrompt = replyPrompt(`${label}-after-stop`);
|
|
const afterAbortReply = replyText(`${label}-after-stop`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, firstPrompt, firstReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
await sendAndExpectReply(page, secondPrompt, secondReply);
|
|
|
|
const slowStart = await sendMessage(page, abortPrompt);
|
|
expect(slowStart.ok()).toBeTruthy();
|
|
await expect(messagesView(page).getByText(abortReply)).toBeVisible({ timeout: 30000 });
|
|
|
|
const [abortResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === 'POST' &&
|
|
response.url().includes('/api/agents/chat/abort'),
|
|
{ timeout: 30000 },
|
|
),
|
|
page.getByRole('button', { name: 'Stop generating' }).click(),
|
|
]);
|
|
expect(abortResponse.ok()).toBeTruthy();
|
|
await expect(page.getByRole('button', { name: 'Stop generating' })).toBeHidden({
|
|
timeout: 30000,
|
|
});
|
|
|
|
let messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(abortReply)),
|
|
'aborted response to persist',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, secondPrompt, firstReply, true);
|
|
expectParent(messages, abortReply, abortPrompt, false);
|
|
|
|
await sendAndExpectReply(page, afterAbortPrompt, afterAbortReply);
|
|
messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(afterAbortReply)),
|
|
'follow-up after abort',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, afterAbortPrompt, abortReply, true);
|
|
expectParent(messages, afterAbortReply, afterAbortPrompt, false);
|
|
|
|
await reloadAndExpectMessages(page, [firstReply, secondReply, abortReply, afterAbortReply]);
|
|
await revisitConversationAndExpectMessages(page, conversationId, [
|
|
firstReply,
|
|
secondReply,
|
|
abortReply,
|
|
afterAbortReply,
|
|
]);
|
|
});
|
|
|
|
test('regenerates assistant siblings, cycles branches, follows up, and forks the visible branch', async ({
|
|
page,
|
|
}) => {
|
|
const label = uniqueLabel('regen');
|
|
const prompt = countedPrompt(label);
|
|
const firstReply = countedReplyText(label, 1);
|
|
const regeneratedReply = countedReplyText(label, 2);
|
|
const followPrompt = replyPrompt(`${label}-follow`);
|
|
const followReply = replyText(`${label}-follow`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, prompt, firstReply);
|
|
const originalConversationId = await conversationIdFromPage(page);
|
|
|
|
await waitForGenerationStart(page, () =>
|
|
clickMessageTitleButton(page, firstReply, 'Regenerate'),
|
|
);
|
|
await expect(messagesView(page).getByText(regeneratedReply)).toBeVisible({ timeout: 30000 });
|
|
|
|
await clickSibling(page, regeneratedReply, 'Previous');
|
|
await expect(messagesView(page).getByText(firstReply)).toBeVisible();
|
|
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
|
|
await clickSibling(page, firstReply, 'Next');
|
|
await expect(messagesView(page).getByText(regeneratedReply)).toBeVisible();
|
|
|
|
await sendAndExpectReply(page, followPrompt, followReply);
|
|
let messages = await waitForMessages(
|
|
page,
|
|
originalConversationId,
|
|
(items) => items.some((message) => messageText(message).includes(followReply)),
|
|
'follow-up after regenerate',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, firstReply, prompt, false);
|
|
expectParent(messages, regeneratedReply, prompt, false);
|
|
expectParent(messages, followPrompt, regeneratedReply, true);
|
|
expectParent(messages, followReply, followPrompt, false);
|
|
|
|
const userMessage = findMessage(messages, prompt, true);
|
|
const assistantSiblings = messages.filter(
|
|
(message) => message.parentMessageId === userMessage.messageId && !message.isCreatedByUser,
|
|
);
|
|
expect(assistantSiblings.map(messageText).sort()).toEqual(
|
|
[firstReply, regeneratedReply].sort(),
|
|
);
|
|
|
|
await reloadAndExpectMessages(page, [regeneratedReply, followReply]);
|
|
await revisitConversationAndExpectMessages(page, originalConversationId, [
|
|
regeneratedReply,
|
|
followReply,
|
|
]);
|
|
await clickSibling(page, regeneratedReply, 'Previous');
|
|
await expect(messagesView(page).getByText(firstReply)).toBeVisible();
|
|
const fork = await clickForkVisibleMessages(page, firstReply);
|
|
const forkedConversationId = fork.conversation.conversationId;
|
|
if (!forkedConversationId) {
|
|
throw new Error('Expected fork response to include a conversation id');
|
|
}
|
|
await expect(page).toHaveURL(new RegExp(`/c/${forkedConversationId}$`));
|
|
|
|
messages = fork.messages;
|
|
expectNoFoldedMessages(messages);
|
|
expect(messages.some((message) => messageText(message).includes(firstReply))).toBe(true);
|
|
expect(messages.some((message) => messageText(message).includes(regeneratedReply))).toBe(false);
|
|
expect(messages.some((message) => messageText(message).includes(followReply))).toBe(false);
|
|
});
|
|
|
|
test('shows the regenerating response immediately when regenerating a non-latest sibling', async ({
|
|
page,
|
|
}) => {
|
|
// A parent with multiple siblings, switched to an older one, then
|
|
// regenerated: the optimistic slice drops the target but keeps the other
|
|
// siblings, so the child count is unchanged and MultiMessage's
|
|
// length-change reset never fires. Without an explicit focus the view stays
|
|
// on the kept (newer) sibling and the streaming response is hidden until the
|
|
// server restores the dropped sibling at finalize. A slow reply keeps the
|
|
// stream open long enough to assert the during-stream state.
|
|
const label = uniqueLabel('regen-nonlatest');
|
|
const prompt = slowCountedPrompt(label);
|
|
const reply1 = slowCountedReplyText(label, 1);
|
|
const reply2 = slowCountedReplyText(label, 2);
|
|
const reply3 = slowCountedReplyText(label, 3);
|
|
const stopButton = page.getByRole('button', { name: 'Stop generating' });
|
|
|
|
await openMockChat(page);
|
|
await sendMessage(page, prompt);
|
|
await expect(messagesView(page).getByText(reply1)).toBeVisible({ timeout: 30000 });
|
|
await expect(stopButton).toBeHidden({ timeout: 30000 });
|
|
|
|
// First regenerate adds a sibling; the parent now has two responses.
|
|
await waitForGenerationStart(page, () => clickMessageTitleButton(page, reply1, 'Regenerate'));
|
|
await expect(messagesView(page).getByText(reply2)).toBeVisible({ timeout: 30000 });
|
|
await expect(stopButton).toBeHidden({ timeout: 30000 });
|
|
|
|
// View the older sibling, then regenerate it (the non-latest one).
|
|
await clickSibling(page, reply2, 'Previous');
|
|
await expect(messagesView(page).getByText(reply1)).toBeVisible();
|
|
await expect(messagesView(page).getByText(reply2)).toBeHidden();
|
|
|
|
await waitForGenerationStart(page, () => clickMessageTitleButton(page, reply1, 'Regenerate'));
|
|
// Still streaming (slow reply): the regenerating response must be visible
|
|
// now — not the kept sibling — and well before finalize.
|
|
await expect(stopButton).toBeVisible({ timeout: 30000 });
|
|
await expect(messagesView(page).getByText(reply3)).toBeVisible({ timeout: 4000 });
|
|
await expect(messagesView(page).getByText(reply2)).toBeHidden();
|
|
|
|
// It stays put after finalize too.
|
|
await expect(stopButton).toBeHidden({ timeout: 30000 });
|
|
await expect(messagesView(page).getByText(reply3)).toBeVisible();
|
|
});
|
|
|
|
test('does not flash the kept sibling before the created event when regenerating a non-latest sibling', async ({
|
|
page,
|
|
}) => {
|
|
// Same hazard at the optimistic (pre-`created`) render: useChatFunctions
|
|
// appends the placeholder and renders it before the request resolves. Gate
|
|
// the regenerate request so only that optimistic frame is on screen — with
|
|
// no `created` event to fall back on — and assert the kept sibling is
|
|
// already gone (the regenerating placeholder took its place). This isolates
|
|
// the optimistic focus; the createdHandler focus cannot mask a regression
|
|
// because `created` never arrives during the assertion.
|
|
const label = uniqueLabel('regen-precreated');
|
|
const prompt = countedPrompt(label);
|
|
const reply1 = countedReplyText(label, 1);
|
|
const reply2 = countedReplyText(label, 2);
|
|
const reply3 = countedReplyText(label, 3);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, prompt, reply1);
|
|
|
|
await waitForGenerationStart(page, () => clickMessageTitleButton(page, reply1, 'Regenerate'));
|
|
await expect(messagesView(page).getByText(reply2)).toBeVisible({ timeout: 30000 });
|
|
|
|
await clickSibling(page, reply2, 'Previous');
|
|
await expect(messagesView(page).getByText(reply1)).toBeVisible();
|
|
await expect(messagesView(page).getByText(reply2)).toBeHidden();
|
|
|
|
// Hold the SSE stream so the `created` event cannot arrive: the optimistic
|
|
// render is all there is. The chat POST returns a stream id; the stream
|
|
// itself is a separate GET (/api/agents/chat/stream/<id>) — gate that one,
|
|
// not the POST, which must complete to hand back the id.
|
|
let release = () => {};
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route(/\/api\/agents\/chat\/stream\//, async (route: Route) => {
|
|
await gate;
|
|
return route.continue();
|
|
});
|
|
|
|
try {
|
|
await clickMessageTitleButton(page, reply1, 'Regenerate');
|
|
// The stream is gated, so the regenerating response has no text yet.
|
|
await expect(messagesView(page).getByText(reply3)).toBeHidden();
|
|
// Pre-`created` window: the kept sibling must not be the one on screen —
|
|
// the regenerating placeholder has already taken its place.
|
|
await expect(messagesView(page).getByText(reply2)).toBeHidden({ timeout: 5000 });
|
|
await expect(messagesView(page).getByText(reply1)).toBeHidden();
|
|
} finally {
|
|
release();
|
|
}
|
|
|
|
await expect(messagesView(page).getByText(reply3)).toBeVisible({ timeout: 30000 });
|
|
});
|
|
|
|
test('resumes pending OAuth on the selected older branch after reload', async ({ page }) => {
|
|
const label = uniqueLabel('oauth-branch');
|
|
const rootPrompt = countedPrompt(`${label}-root`);
|
|
const firstReply = countedReplyText(`${label}-root`, 1);
|
|
const regeneratedReply = countedReplyText(`${label}-root`, 2);
|
|
const followPrompt = replyPrompt(`${label}-follow`);
|
|
const followReply = replyText(`${label}-follow`);
|
|
const pendingPrompt = replyPrompt(`${label}-oauth`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, rootPrompt, firstReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
await sendAndExpectReply(page, followPrompt, followReply);
|
|
|
|
await waitForGenerationStart(page, () =>
|
|
clickMessageTitleButton(page, firstReply, 'Regenerate'),
|
|
);
|
|
await expect(messagesView(page).getByText(regeneratedReply)).toBeVisible({ timeout: 30000 });
|
|
|
|
await clickSibling(page, regeneratedReply, 'Previous');
|
|
await expectVisibleMessages(page, [firstReply, followPrompt, followReply]);
|
|
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
|
|
|
|
const messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) =>
|
|
items.some((message) => messageText(message).includes(followReply)) &&
|
|
items.some((message) => messageText(message).includes(regeneratedReply)),
|
|
'two-branch conversation',
|
|
);
|
|
const branchOneTail = findMessage(messages, followReply, false);
|
|
|
|
await mockActiveOAuthResumeStream({
|
|
page,
|
|
conversationId,
|
|
parentMessageId: branchOneTail.messageId,
|
|
pendingPrompt,
|
|
pendingUserMessageId: `${label}-pending-user`,
|
|
authUrl: `https://auth.example.test/${label}`,
|
|
});
|
|
|
|
await page.reload({ timeout: 10000 });
|
|
await expectVisibleMessages(page, [firstReply, followPrompt, followReply, pendingPrompt]);
|
|
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
|
|
});
|
|
|
|
test('keeps existing messages visible when resumed OAuth streams post-auth content', async ({
|
|
page,
|
|
}) => {
|
|
const label = uniqueLabel('oauth-post-auth');
|
|
const rootPrompt = replyPrompt(`${label}-root`);
|
|
const rootReply = replyText(`${label}-root`);
|
|
const pendingPrompt = replyPrompt(`${label}-pending`);
|
|
const postAuthText = `E2E post-auth OAuth reply ${label}`;
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, rootPrompt, rootReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
|
|
const messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(rootReply)),
|
|
'root reply before OAuth resume',
|
|
);
|
|
const branchTail = findMessage(messages, rootReply, false);
|
|
|
|
await mockActiveOAuthResumeStream({
|
|
page,
|
|
conversationId,
|
|
parentMessageId: branchTail.messageId,
|
|
pendingPrompt,
|
|
pendingUserMessageId: `${label}-pending-user`,
|
|
authUrl: `https://auth.example.test/${label}`,
|
|
postAuthRunId: `${label}-stable-response`,
|
|
postAuthText,
|
|
});
|
|
|
|
await page.reload({ timeout: 10000 });
|
|
await expectVisibleMessages(page, [rootPrompt, rootReply, pendingPrompt, postAuthText]);
|
|
});
|
|
|
|
test('keeps existing messages visible when OAuth completes before created', async ({ page }) => {
|
|
const label = uniqueLabel('oauth-pre-created');
|
|
const rootPrompt = replyPrompt(`${label}-root`);
|
|
const rootReply = replyText(`${label}-root`);
|
|
const followPrompt = replyPrompt(`${label}-follow`);
|
|
const postAuthText = `E2E pre-created OAuth reply ${label}`;
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, rootPrompt, rootReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
|
|
const messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(rootReply)),
|
|
'root reply before pre-created OAuth stream',
|
|
);
|
|
const branchTail = findMessage(messages, rootReply, false);
|
|
|
|
await mockPreCreatedOAuthStream({
|
|
page,
|
|
conversationId,
|
|
parentMessageId: branchTail.messageId,
|
|
prompt: followPrompt,
|
|
serverUserMessageId: `${label}-server-user`,
|
|
responseMessageId: `${label}-response`,
|
|
authUrl: `https://auth.example.test/${label}`,
|
|
postAuthText,
|
|
});
|
|
|
|
const response = await sendMessage(page, followPrompt);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expectVisibleMessages(page, [rootPrompt, rootReply, followPrompt, postAuthText]);
|
|
});
|
|
|
|
test('long threads retain regenerated and save-and-submit branches after revisit', async ({
|
|
page,
|
|
}) => {
|
|
const label = uniqueLabel('save-submit');
|
|
const rootPrompt = replyPrompt(`${label}-root`);
|
|
const rootReply = replyText(`${label}-root`);
|
|
const firstPrompt = replyPrompt(`${label}-first`);
|
|
const firstReply = replyText(`${label}-first`);
|
|
const middlePrompt = replyPrompt(`${label}-middle`);
|
|
const middleReply = replyText(`${label}-middle`);
|
|
const fourthPrompt = replyPrompt(`${label}-fourth`);
|
|
const fourthReply = replyText(`${label}-fourth`);
|
|
const tailPrompt = countedPrompt(`${label}-tail`);
|
|
const tailReply = countedReplyText(`${label}-tail`, 1);
|
|
const regeneratedTailReply = countedReplyText(`${label}-tail`, 2);
|
|
const editedMiddlePrompt = replyPrompt(`${label}-middle-edited`);
|
|
const editedMiddleReply = replyText(`${label}-middle-edited`);
|
|
const afterEditPrompt = replyPrompt(`${label}-after-edit`);
|
|
const afterEditReply = replyText(`${label}-after-edit`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, rootPrompt, rootReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
await sendAndExpectReply(page, firstPrompt, firstReply);
|
|
await sendAndExpectReply(page, middlePrompt, middleReply);
|
|
await sendAndExpectReply(page, fourthPrompt, fourthReply);
|
|
await sendAndExpectReply(page, tailPrompt, tailReply);
|
|
|
|
await waitForGenerationStart(page, () =>
|
|
clickMessageTitleButton(page, tailReply, 'Regenerate'),
|
|
);
|
|
await expect(messagesView(page).getByText(regeneratedTailReply)).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
|
|
await clickMessageTitleButton(page, middlePrompt, 'Edit');
|
|
const editor = page.getByTestId('message-text-editor');
|
|
await expect(editor).toBeVisible();
|
|
await editor.fill(editedMiddlePrompt);
|
|
await waitForGenerationStart(page, () =>
|
|
page.getByRole('button', { name: 'Save & Submit' }).click(),
|
|
);
|
|
await expect(messagesView(page).getByText(editedMiddleReply)).toBeVisible({ timeout: 30000 });
|
|
|
|
let messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(editedMiddleReply)),
|
|
'save-and-submit edited branch',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, firstPrompt, rootReply, true);
|
|
expectParent(messages, firstReply, firstPrompt, false);
|
|
expectParent(messages, middlePrompt, firstReply, true);
|
|
expectParent(messages, middleReply, middlePrompt, false);
|
|
expectParent(messages, fourthPrompt, middleReply, true);
|
|
expectParent(messages, fourthReply, fourthPrompt, false);
|
|
expectParent(messages, tailPrompt, fourthReply, true);
|
|
expectParent(messages, tailReply, tailPrompt, false);
|
|
expectParent(messages, regeneratedTailReply, tailPrompt, false);
|
|
expectParent(messages, editedMiddlePrompt, firstReply, true);
|
|
expectParent(messages, editedMiddleReply, editedMiddlePrompt, false);
|
|
|
|
await clickSibling(page, editedMiddlePrompt, 'Previous');
|
|
await expectVisibleMessages(page, [middlePrompt, fourthReply]);
|
|
await expectCanCycleSiblingTexts(page, tailReply, regeneratedTailReply);
|
|
await clickSibling(page, middlePrompt, 'Next');
|
|
await expectVisibleMessages(page, [editedMiddlePrompt, editedMiddleReply]);
|
|
|
|
await sendAndExpectReply(page, afterEditPrompt, afterEditReply);
|
|
messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(afterEditReply)),
|
|
'follow-up after save-and-submit branch',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, afterEditPrompt, editedMiddleReply, true);
|
|
expectParent(messages, afterEditReply, afterEditPrompt, false);
|
|
expect(messages.some((message) => messageText(message).includes(tailReply))).toBe(true);
|
|
expect(messages.some((message) => messageText(message).includes(regeneratedTailReply))).toBe(
|
|
true,
|
|
);
|
|
|
|
await reloadAndExpectMessages(page, [rootReply, firstReply, editedMiddleReply, afterEditReply]);
|
|
await revisitConversationAndExpectMessages(page, conversationId, [
|
|
rootReply,
|
|
firstReply,
|
|
editedMiddleReply,
|
|
afterEditReply,
|
|
]);
|
|
await clickSibling(page, editedMiddlePrompt, 'Previous');
|
|
await expectVisibleMessages(page, [middlePrompt, fourthReply]);
|
|
await expectCanCycleSiblingTexts(page, tailReply, regeneratedTailReply);
|
|
await clickSibling(page, middlePrompt, 'Next');
|
|
await expectVisibleMessages(page, [editedMiddlePrompt, editedMiddleReply, afterEditReply]);
|
|
});
|
|
|
|
test('error responses remain valid parents for follow-ups', async ({ page }) => {
|
|
const label = uniqueLabel('error');
|
|
const basePrompt = replyPrompt(`${label}-base`);
|
|
const baseReply = replyText(`${label}-base`);
|
|
const errorPrompt = `E2E_FORCED_ERROR:${label}`;
|
|
const errorText = `E2E forced stream error ${label}`;
|
|
const afterErrorPrompt = replyPrompt(`${label}-after-error`);
|
|
const afterErrorReply = replyText(`${label}-after-error`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, basePrompt, baseReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
|
|
await sendAndExpectReply(page, errorPrompt, errorText);
|
|
await expect(messagesView(page).getByText(errorText)).toBeVisible({ timeout: 30000 });
|
|
|
|
await sendAndExpectReply(page, afterErrorPrompt, afterErrorReply);
|
|
const messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(afterErrorReply)),
|
|
'follow-up after error',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, errorPrompt, baseReply, true);
|
|
expectParent(messages, errorText, errorPrompt, false);
|
|
expectParent(messages, afterErrorPrompt, errorText, true);
|
|
expectParent(messages, afterErrorReply, afterErrorPrompt, false);
|
|
|
|
await reloadAndExpectMessages(page, [baseReply, errorText, afterErrorReply]);
|
|
await revisitConversationAndExpectMessages(page, conversationId, [
|
|
baseReply,
|
|
errorText,
|
|
afterErrorReply,
|
|
]);
|
|
});
|
|
|
|
test('rejects normal follow-ups whose parent is a preliminary assistant placeholder', async ({
|
|
page,
|
|
}) => {
|
|
const label = uniqueLabel('placeholder-parent');
|
|
const basePrompt = replyPrompt(`${label}-base`);
|
|
const baseReply = replyText(`${label}-base`);
|
|
const followPrompt = replyPrompt(`${label}-follow`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, basePrompt, baseReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
const token = await getAccessToken(page);
|
|
|
|
const beforeMessages = await fetchMessages(page, conversationId, token);
|
|
const stableParent = findMessage(beforeMessages, baseReply, false);
|
|
const response = await postJsonWithStatus(
|
|
page,
|
|
`/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`,
|
|
token,
|
|
{
|
|
text: followPrompt,
|
|
sender: 'User',
|
|
clientTimestamp: new Date().toLocaleString('sv').replace(' ', 'T'),
|
|
isCreatedByUser: true,
|
|
parentMessageId: `${stableParent.messageId}_`,
|
|
conversationId,
|
|
messageId: `${label}-user-message`,
|
|
endpoint: MOCK_ENDPOINTS[0].label,
|
|
endpointType: 'custom',
|
|
model: MOCK_ENDPOINTS[0].model,
|
|
spec: 'e2e-mock-provider-a',
|
|
isTemporary: false,
|
|
isRegenerate: false,
|
|
error: false,
|
|
},
|
|
);
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.json).toEqual(
|
|
expect.objectContaining({
|
|
error: expect.stringContaining('selected parent response is still being saved'),
|
|
}),
|
|
);
|
|
|
|
const afterMessages = await fetchMessages(page, conversationId, token);
|
|
expect(afterMessages.map((message) => message.messageId).sort()).toEqual(
|
|
beforeMessages.map((message) => message.messageId).sort(),
|
|
);
|
|
expect(afterMessages.some((message) => messageText(message).includes(followPrompt))).toBe(
|
|
false,
|
|
);
|
|
expectNoFoldedMessages(afterMessages);
|
|
});
|
|
|
|
test('generation-start failures recover without folding the next follow-up', async ({ page }) => {
|
|
const label = uniqueLabel('start-error');
|
|
const basePrompt = replyPrompt(`${label}-base`);
|
|
const baseReply = replyText(`${label}-base`);
|
|
const failedPrompt = replyPrompt(`${label}-failed-start`);
|
|
const failedText = `E2E generation start failure ${label}`;
|
|
const afterFailurePrompt = replyPrompt(`${label}-after-start-failure`);
|
|
const afterFailureReply = replyText(`${label}-after-start-failure`);
|
|
|
|
await openMockChat(page);
|
|
await sendAndExpectReply(page, basePrompt, baseReply);
|
|
const conversationId = await conversationIdFromPage(page);
|
|
|
|
const failGenerationStart = async (route: Route) => {
|
|
const request = route.request();
|
|
const { pathname } = new URL(request.url());
|
|
const isAgentsChat =
|
|
pathname === '/api/agents/chat' || pathname.startsWith('/api/agents/chat/');
|
|
if (
|
|
request.method() !== 'POST' ||
|
|
!isAgentsChat ||
|
|
pathname.endsWith('/abort') ||
|
|
!request.postData()?.includes(failedPrompt)
|
|
) {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 500,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ message: failedText }),
|
|
});
|
|
};
|
|
await page.route('**/api/agents/chat**', failGenerationStart);
|
|
|
|
const failure = await submitMessageExpectingGenerationFailure(page, failedPrompt, 500);
|
|
expect(failure.ok()).toBe(false);
|
|
await expect(messagesView(page).getByText(failedText)).toBeVisible({ timeout: 30000 });
|
|
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeEnabled({
|
|
timeout: 30000,
|
|
});
|
|
await page.unroute('**/api/agents/chat**', failGenerationStart);
|
|
|
|
await sendAndExpectReply(page, afterFailurePrompt, afterFailureReply);
|
|
const messages = await waitForMessages(
|
|
page,
|
|
conversationId,
|
|
(items) => items.some((message) => messageText(message).includes(afterFailureReply)),
|
|
'follow-up after generation-start failure',
|
|
);
|
|
expectNoFoldedMessages(messages);
|
|
expectParent(messages, afterFailurePrompt, baseReply, true);
|
|
expectParent(messages, afterFailureReply, afterFailurePrompt, false);
|
|
expect(messages.some((message) => messageText(message).includes(failedPrompt))).toBe(false);
|
|
expect(messages.some((message) => messageText(message).includes(failedText))).toBe(false);
|
|
|
|
await reloadAndExpectMessages(page, [baseReply, afterFailureReply]);
|
|
await expect(messagesView(page).getByText(failedText)).toBeHidden();
|
|
await revisitConversationAndExpectMessages(page, conversationId, [
|
|
baseReply,
|
|
afterFailureReply,
|
|
]);
|
|
await expect(messagesView(page).getByText(failedText)).toBeHidden();
|
|
});
|
|
});
|