|
20 | 20 | */ |
21 | 21 |
|
22 | 22 | import { type ReactNode, act as reactAct, StrictMode, useEffect, useState } from 'react' |
| 23 | +import { createDeferred } from '@sim/testing/helpers/deferred' |
23 | 24 | import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock' |
24 | 25 | import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock' |
25 | 26 | import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' |
@@ -90,6 +91,7 @@ import { |
90 | 91 | import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' |
91 | 92 | import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants' |
92 | 93 | import type { MothershipStreamV1EventEnvelope } from '@/lib/mothership/generated/mothership-stream-v1' |
| 94 | +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' |
93 | 95 | import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' |
94 | 96 | import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' |
95 | 97 | import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' |
@@ -749,6 +751,190 @@ describe('useChat remount send recovery', () => { |
749 | 751 | } |
750 | 752 | ) |
751 | 753 |
|
| 754 | + describe('a tab the server removed while the client was still adding it', () => { |
| 755 | + const chat: MothershipChatHistory = { |
| 756 | + id: 'chat-removed-file', |
| 757 | + title: 'Files', |
| 758 | + messages: [], |
| 759 | + activeStreamId: null, |
| 760 | + resources: [], |
| 761 | + } |
| 762 | + |
| 763 | + /** |
| 764 | + * Streams a turn that opens a file tab without an effect (the client |
| 765 | + * persists it), then the server's own removal of that file as an effect. |
| 766 | + * Later turns only complete. The removal is held until `addSent` resolves, |
| 767 | + * so the client's own write for the add has started by then. Returns each |
| 768 | + * send's resource attachments. |
| 769 | + */ |
| 770 | + function streamAddThenServerRemoval(addSent: Promise<void>): unknown[] { |
| 771 | + const sentAttachments: unknown[] = [] |
| 772 | + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { |
| 773 | + if (String(input) !== '/api/mothership/chat' || init?.method !== 'POST') { |
| 774 | + return fetchStub(input, init) |
| 775 | + } |
| 776 | + const sent = JSON.parse(String(init.body)) |
| 777 | + sentAttachments.push(sent.resourceAttachments) |
| 778 | + const envelope = { v: 1 as const, ts: '', stream: { streamId: sent.userMessageId } } |
| 779 | + const file = { type: 'file' as const, id: 'deleted-file', title: 'report.csv' } |
| 780 | + const complete: PersistedStreamEventEnvelope = { |
| 781 | + ...envelope, |
| 782 | + seq: 3, |
| 783 | + type: 'complete', |
| 784 | + payload: { status: 'complete' }, |
| 785 | + } |
| 786 | + const events: PersistedStreamEventEnvelope[] = |
| 787 | + sentAttachments.length === 1 |
| 788 | + ? [ |
| 789 | + { |
| 790 | + ...envelope, |
| 791 | + seq: 1, |
| 792 | + type: 'resource', |
| 793 | + payload: { op: 'upsert', resource: file }, |
| 794 | + }, |
| 795 | + { |
| 796 | + ...envelope, |
| 797 | + seq: 2, |
| 798 | + type: 'resource', |
| 799 | + payload: { op: 'remove', resource: file, effectId: 'remove-deleted-file' }, |
| 800 | + }, |
| 801 | + complete, |
| 802 | + ] |
| 803 | + : [complete] |
| 804 | + const encoder = new TextEncoder() |
| 805 | + const body = new ReadableStream<Uint8Array>({ |
| 806 | + async start(controller) { |
| 807 | + const [first, ...rest] = events |
| 808 | + controller.enqueue(encoder.encode(`data: ${JSON.stringify(first)}\n\n`)) |
| 809 | + if (rest.length > 0) { |
| 810 | + await addSent |
| 811 | + // One macrotask lets a write that already failed settle in the queue. |
| 812 | + await sleep(0) |
| 813 | + } |
| 814 | + for (const event of rest) { |
| 815 | + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) |
| 816 | + } |
| 817 | + controller.close() |
| 818 | + }, |
| 819 | + }) |
| 820 | + return new Response(body, { |
| 821 | + headers: { 'Content-Type': 'text/event-stream', 'x-mothership-chat-id': chat.id }, |
| 822 | + }) |
| 823 | + }) |
| 824 | + return sentAttachments |
| 825 | + } |
| 826 | + |
| 827 | + /** |
| 828 | + * Resource writes the hook sent, as `METHOD id`, and a promise that |
| 829 | + * resolves once the first add has been sent. |
| 830 | + */ |
| 831 | + function recordResourceWrites(persist: () => Promise<unknown>): { |
| 832 | + writes: string[] |
| 833 | + addSent: Promise<void> |
| 834 | + } { |
| 835 | + const writes: string[] = [] |
| 836 | + const addSent = createDeferred<void>() |
| 837 | + mockRequestJson.mockImplementation(async (contract: AnyApiRouteContract, input) => { |
| 838 | + if (contract.path !== '/api/mothership/chat/resources') return { chats: [] } |
| 839 | + const { body } = input as { body: { resource?: { id: string }; resourceId?: string } } |
| 840 | + writes.push(`${contract.method} ${body.resource?.id ?? body.resourceId}`) |
| 841 | + if (contract.method !== 'POST') return { resources: [] } |
| 842 | + addSent.resolve() |
| 843 | + return persist() |
| 844 | + }) |
| 845 | + return { writes, addSent: addSent.promise } |
| 846 | + } |
| 847 | + |
| 848 | + it('drops a failed add without re-posting it or deleting server-side', async () => { |
| 849 | + const { writes, addSent } = recordResourceWrites(async () => { |
| 850 | + throw new Error('Persist failed') |
| 851 | + }) |
| 852 | + const sentAttachments = streamAddThenServerRemoval(addSent) |
| 853 | + const { getResult } = renderUseChatInChat(chat.id, chat) |
| 854 | + |
| 855 | + await act(async () => { |
| 856 | + await getResult().sendMessage('Export the report, then delete it') |
| 857 | + }) |
| 858 | + const writesBeforeRemoval = [...writes] |
| 859 | + await act(async () => { |
| 860 | + queryClient.setQueryData(mothershipChatKeys.detail(chat.id), { ...chat }) |
| 861 | + await sleep(0) |
| 862 | + }) |
| 863 | + await act(async () => { |
| 864 | + await getResult().sendMessage('What is open?') |
| 865 | + }) |
| 866 | + |
| 867 | + expect(writesBeforeRemoval).not.toContain('DELETE deleted-file') |
| 868 | + expect(writes).toEqual(writesBeforeRemoval) |
| 869 | + expect(getResult().resources.map((resource) => resource.id)).not.toContain('deleted-file') |
| 870 | + expect(JSON.stringify(sentAttachments.at(-1) ?? [])).not.toContain('deleted-file') |
| 871 | + }) |
| 872 | + |
| 873 | + it('drops a failed add when the removal arrives in a reconnect replay', async () => { |
| 874 | + const { writes } = recordResourceWrites(async () => { |
| 875 | + throw new Error('Persist failed') |
| 876 | + }) |
| 877 | + const file = { type: 'file' as const, id: 'deleted-file', title: 'report.csv' } |
| 878 | + const sentAttachments: unknown[] = [] |
| 879 | + let streamId = '' |
| 880 | + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { |
| 881 | + const url = String(input) |
| 882 | + if (url.includes('/api/mothership/chat/stream')) { |
| 883 | + const removal: PersistedStreamEventEnvelope = { |
| 884 | + v: 1, |
| 885 | + ts: '', |
| 886 | + stream: { streamId }, |
| 887 | + seq: 2, |
| 888 | + type: 'resource', |
| 889 | + payload: { op: 'remove', resource: file, effectId: 'remove-deleted-file' }, |
| 890 | + } |
| 891 | + const opened: PersistedStreamEventEnvelope = { |
| 892 | + ...removal, |
| 893 | + seq: 1, |
| 894 | + payload: { op: 'upsert', resource: file }, |
| 895 | + } |
| 896 | + return Response.json({ |
| 897 | + success: true, |
| 898 | + events: [opened, removal].map((event) => ({ eventId: event.seq, streamId, event })), |
| 899 | + status: 'complete', |
| 900 | + }) |
| 901 | + } |
| 902 | + if (url !== '/api/mothership/chat' || init?.method !== 'POST') { |
| 903 | + return fetchStub(input, init) |
| 904 | + } |
| 905 | + const sent = JSON.parse(String(init.body)) |
| 906 | + sentAttachments.push(sent.resourceAttachments) |
| 907 | + streamId = sent.userMessageId |
| 908 | + const envelope = { v: 1 as const, ts: '', stream: { streamId } } |
| 909 | + const events: PersistedStreamEventEnvelope[] = |
| 910 | + sentAttachments.length === 1 |
| 911 | + ? [{ ...envelope, seq: 1, type: 'resource', payload: { op: 'upsert', resource: file } }] |
| 912 | + : [{ ...envelope, seq: 1, type: 'complete', payload: { status: 'complete' } }] |
| 913 | + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { |
| 914 | + headers: { 'Content-Type': 'text/event-stream', 'x-mothership-chat-id': chat.id }, |
| 915 | + }) |
| 916 | + }) |
| 917 | + const { getResult } = renderUseChatInChat(chat.id, chat) |
| 918 | + |
| 919 | + await act(async () => { |
| 920 | + await getResult().sendMessage('Export the report, then delete it') |
| 921 | + }) |
| 922 | + await waitFor(() => !getResult().isSending) |
| 923 | + const writesBeforeRemoval = [...writes] |
| 924 | + await act(async () => { |
| 925 | + queryClient.setQueryData(mothershipChatKeys.detail(chat.id), { ...chat }) |
| 926 | + await sleep(0) |
| 927 | + }) |
| 928 | + await act(async () => { |
| 929 | + await getResult().sendMessage('What is open?') |
| 930 | + }) |
| 931 | + |
| 932 | + expect(writes).toEqual(writesBeforeRemoval) |
| 933 | + expect(getResult().resources.map((resource) => resource.id)).not.toContain('deleted-file') |
| 934 | + expect(JSON.stringify(sentAttachments.at(-1) ?? [])).not.toContain('deleted-file') |
| 935 | + }) |
| 936 | + }) |
| 937 | + |
752 | 938 | it('restores an explicitly selected Search tab while reconnecting an active turn', async () => { |
753 | 939 | const shape = resolveDeploymentShape() |
754 | 940 | seedDeploymentShape({ ...shape, features: shape.features }) |
|
0 commit comments