import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ConversationSnapshot } from '@/types/coding-conversation'; import type { CodingConversationMetadata, CodingProjectAgent, CodingProjectConfig, CodingProjectSummary, } from '@/types/coding-project'; function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } const runtimeSnapshot = deferred(); class FakeEventSource { onopen: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; readonly close = vi.fn(); addEventListener = vi.fn(); } const source = new FakeEventSource(); const project: CodingProjectSummary = { id: 'project-1', name: 'Local project', createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', lastOpenedAt: '2026-08-23T00:00:00.000Z', }; const agent: CodingProjectAgent = { id: 'agent-1', avatarId: 'default', roleName: '伙伴', name: 'Builder', builtIn: false, enabled: true, skillIds: [], responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [], }, prompt: '', archivedAt: null, pinned: true, createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', model: null, modelResolution: 'required', }; const config: CodingProjectConfig = { schemaVersion: 2, projectType: 'custom', initialized: true, agents: [agent], knowledgeDirectory: 'knowledge', legacyConversationNotice: 'none', createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', }; const conversation: CodingConversationMetadata = { id: 'conversation-1', agentId: agent.id, title: '新对话', archivedAt: null, unread: false, createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', model: null, modelResolution: 'required', }; const reviewer: CodingProjectAgent = { ...agent, id: 'agent-2', name: 'Reviewer', pinned: false, }; const reviewerConversation: CodingConversationMetadata = { ...conversation, id: 'conversation-2', agentId: reviewer.id, title: 'Reviewer conversation', }; function configForAgents(agents: CodingProjectAgent[]): CodingProjectConfig { return { ...config, agents }; } const projectApi = vi.hoisted(() => ({ list: vi.fn(), config: vi.fn(), conversations: vi.fn(), create: vi.fn(), })); const conversationApi = vi.hoisted(() => ({ snapshot: vi.fn(), events: vi.fn(), submit: vi.fn(), recover: vi.fn(), })); const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() })); vi.mock('@/lib/coding-projects', () => ({ listCodingProjects: projectApi.list, getCodingProjectConfig: projectApi.config, listCodingProjectConversations: projectApi.conversations, createCodingProjectConversation: projectApi.create, })); vi.mock('@/lib/coding-conversations', () => ({ getCodingConversationSnapshot: conversationApi.snapshot, openCodingConversationEvents: conversationApi.events, submitCodingConversationPrompt: conversationApi.submit, recoverCodingConversation: conversationApi.recover, })); vi.mock('@/lib/coding-attachments', async (importOriginal) => ({ ...await importOriginal(), uploadCodingAttachment: attachmentApi.upload, })); describe('CodingChatPanel first Conversation', () => { beforeEach(() => { Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((file: File) => `blob:${file.name}`), }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn(), }); }); afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules(); }); it('makes the first-Conversation textarea editable while runtime metadata is held', async () => { projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); projectApi.conversations.mockResolvedValue([]); projectApi.create.mockResolvedValue(conversation); conversationApi.snapshot.mockReturnValue(runtimeSnapshot.promise); conversationApi.events.mockResolvedValue(source as unknown as EventSource); conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used')); conversationApi.recover.mockResolvedValue(undefined); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import( '@/pages/Chat/coding-chat-snapshot' ); const view = render(); const textbox = await screen.findByRole('textbox'); expect(textbox).toBeEnabled(); expect(projectApi.create).toHaveBeenCalledOnce(); expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id); fireEvent.change(textbox, { target: { value: 'First prompt' } }); const sendButton = screen.getByRole('button', { name: '发送' }); await waitFor(() => expect(sendButton).toBeEnabled()); expect(textbox).toHaveValue('First prompt'); expect(conversationApi.submit).not.toHaveBeenCalled(); await act(async () => { runtimeSnapshot.resolve({ ...createLocalConversationSnapshot(project.id, conversation), worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq: 0 }, }); await runtimeSnapshot.promise; }); view.unmount(); expect(source.close).toHaveBeenCalled(); }); it('keeps the new core Chat Renderer free of legacy OpenCode imports', async () => { const files = [ '../../src/pages/Chat/CodingChatPanel.tsx', '../../src/pages/Chat/CodingComposer.tsx', '../../src/pages/Chat/CodingConversationTimeline.tsx', '../../src/stores/coding-workspace.ts', ]; for (const relativePath of files) { const sourceText = await readFile( fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8', ); expect(sourceText).not.toMatch(/(?:import|export)[\s\S]*?from\s+['"][^'"]*opencode/i); } }); it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => { const slowCreate = deferred(); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) }); projectApi.conversations.mockResolvedValue([reviewerConversation]); projectApi.create.mockReturnValue(slowCreate.promise); conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used')); conversationApi.recover.mockResolvedValue(undefined); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const { codingConversationStore } = await import('@/stores/coding-conversations'); conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( createLocalConversationSnapshot( project.id, conversationId === reviewerConversation.id ? reviewerConversation : conversation, ) )); render(); await screen.findByRole('textbox'); await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({ projectId: project.id, agentId: agent.id, title: '新对话', })); fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(reviewerConversation.id)); await act(async () => { slowCreate.resolve(conversation); await slowCreate.promise; }); expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id); }); it('keeps submission state and rejection scoped to the originating Conversation', async () => { const pendingSubmit = deferred(); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) }); projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]); conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise); conversationApi.recover.mockResolvedValue(undefined); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const { codingConversationStore } = await import('@/stores/coding-conversations'); conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( createLocalConversationSnapshot( project.id, conversationId === reviewerConversation.id ? reviewerConversation : conversation, ) )); render(); const textbox = await screen.findByRole('textbox'); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); fireEvent.change(textbox, { target: { value: 'A prompt' } }); await waitFor(() => expect( codingConversationStore.getState().draftsByConversationId[conversation.id]?.text, ).toBe('A prompt')); expect(codingConversationStore.getState().entriesByConversationId[conversation.id]) .toMatchObject({ loadState: 'live', error: null }); await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce()); fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('')); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } }); await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled()); await act(async () => { pendingSubmit.reject(new Error('A submission failed')); await pendingSubmit.promise.catch(() => undefined); }); expect(screen.queryByText('A submission failed')).not.toBeInTheDocument(); expect(screen.getByRole('textbox')).toHaveValue('B prompt'); expect(screen.getByRole('button', { name: '发送' })).toBeEnabled(); }); it('caps one message at 16 images and uploads at most four concurrently', async () => { projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); projectApi.conversations.mockResolvedValue([conversation]); conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); conversationApi.recover.mockResolvedValue(undefined); conversationApi.submit.mockImplementation(async (input: { conversationId: string; clientRequestId: string; mode: 'prompt'; }) => ({ accepted: true, conversationId: input.conversationId, clientRequestId: input.clientRequestId, runId: 'run-1', mode: input.mode, })); const uploadFlights: Array>> = []; let activeUploads = 0; let maxActiveUploads = 0; attachmentApi.upload.mockImplementation((file: File) => { const flight = deferred<{ attachmentId: string; mime: string; byteLength: number }>(); uploadFlights.push(flight); activeUploads += 1; maxActiveUploads = Math.max(maxActiveUploads, activeUploads); return flight.promise.finally(() => { activeUploads -= 1; }).then(() => ({ attachmentId: `attachment-${file.name}`, mime: file.type, byteLength: file.size, })); }); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const { codingConversationStore } = await import('@/stores/coding-conversations'); conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation)); render(); await screen.findByRole('textbox'); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); const files = Array.from({ length: 17 }, (_, index) => new File( [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, index])], `image-${index}.png`, { type: 'image/png' }, )); fireEvent.change(screen.getByTestId('coding-file-attachment-input'), { target: { files }, }); expect(await screen.findByText('每条消息最多添加 16 张图片。')).toBeInTheDocument(); expect(screen.getAllByRole('img')).toHaveLength(16); expect(codingConversationStore.getState().entriesByConversationId[conversation.id]) .toMatchObject({ loadState: 'live', error: null }); await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(4)); let resolved = 0; while (resolved < 16) { const available = uploadFlights.slice(resolved); for (const flight of available) flight.resolve({ attachmentId: `resolved-${resolved++}`, mime: 'image/png', byteLength: 9, }); if (resolved < 16) { await waitFor(() => expect(uploadFlights.length).toBeGreaterThan(resolved)); } } await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce()); expect(attachmentApi.upload).toHaveBeenCalledTimes(16); expect(maxActiveUploads).toBeLessThanOrEqual(4); }); it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => { const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); const onSubmit = vi.fn(); render( , ); expect(screen.getByText('1 条消息已被本地 Agent 接收。')).toBeInTheDocument(); const textbox = screen.getByRole('textbox'); fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter', shiftKey: true }); expect(onSubmit).not.toHaveBeenCalled(); fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }); expect(onSubmit).toHaveBeenCalledOnce(); }); });