import { act, fireEvent, render, screen, waitFor, within } 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 { AppError } from '@/lib/error-model'; 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 }; } function withForkSource( snapshot: ConversationSnapshot, sourceEntryId: string, ): ConversationSnapshot { return { ...snapshot, nodes: [{ kind: 'message', id: `message-${sourceEntryId}`, sourceEntryId, role: 'user', status: 'complete', blocks: [{ kind: 'text', id: `text-${sourceEntryId}`, text: 'Fork source', status: 'complete', }], }], }; } 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', 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(), patch: vi.fn(), open: vi.fn(), createProject: vi.fn(), setActive: vi.fn(), removeProject: vi.fn(), })); const conversationApi = vi.hoisted(() => ({ snapshot: vi.fn(), events: vi.fn(), submit: vi.fn(), recover: vi.fn(), abort: vi.fn(), model: vi.fn(), thinking: vi.fn(), fork: vi.fn(), respond: vi.fn(), diagnostics: 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, patchCodingProjectConversation: projectApi.patch, openCodingProject: projectApi.open, createCodingProject: projectApi.createProject, setActiveCodingProject: projectApi.setActive, removeCodingProject: projectApi.removeProject, })); vi.mock('@/lib/coding-conversations', () => ({ getCodingConversationSnapshot: conversationApi.snapshot, openCodingConversationEvents: conversationApi.events, submitCodingConversationPrompt: conversationApi.submit, recoverCodingConversation: conversationApi.recover, abortCodingConversation: conversationApi.abort, setCodingConversationModel: conversationApi.model, setCodingConversationThinking: conversationApi.thinking, forkCodingConversation: conversationApi.fork, respondCodingConversationInteraction: conversationApi.respond, getCodingRuntimeDiagnostics: conversationApi.diagnostics, })); 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); expect(sourceText).not.toMatch(/['"](?:share|unshare|unrevert|revert|todo|todos|global runtime)['"]/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('renders Conversations as children of only the selected Agent', async () => { 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.recover.mockResolvedValue(undefined); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( createLocalConversationSnapshot( project.id, conversationId === reviewerConversation.id ? reviewerConversation : conversation, ) )); render(); const builderButton = await screen.findByRole('button', { name: agent.name }); const reviewerButton = screen.getByRole('button', { name: reviewer.name }); expect(builderButton).toHaveAttribute('aria-expanded', 'true'); expect(reviewerButton).toHaveAttribute('aria-expanded', 'false'); const builderConversations = screen.getByRole('group', { name: `${agent.name} 的对话` }); expect(within(builderConversations).getByRole('button', { name: conversation.title })).toBeVisible(); expect(screen.queryByText(reviewerConversation.title)).not.toBeInTheDocument(); fireEvent.click(reviewerButton); await waitFor(() => expect(reviewerButton).toHaveAttribute('aria-expanded', 'true')); expect(builderButton).toHaveAttribute('aria-expanded', 'false'); const reviewerConversations = screen.getByRole('group', { name: `${reviewer.name} 的对话` }); expect(within(reviewerConversations) .getByRole('button', { name: reviewerConversation.title })).toBeVisible(); expect(screen.queryByText(conversation.title)).not.toBeInTheDocument(); }); it('does not let a pending message fork steal selection after a Conversation switch', async () => { const forkFlight = 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.recover.mockResolvedValue(undefined); conversationApi.fork.mockReturnValue(forkFlight.promise); 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) => ( withForkSource(createLocalConversationSnapshot( project.id, conversationId === reviewerConversation.id ? reviewerConversation : conversation, ), `entry-${conversationId}`) )); render(); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' })); await waitFor(() => expect(conversationApi.fork) .toHaveBeenCalledWith(conversation.id, `entry-${conversation.id}`)); fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(reviewerConversation.id)); expect(screen.getByRole('button', { name: '从这里创建新对话分支' })).toBeEnabled(); await act(async () => { forkFlight.resolve({ ...conversation, id: 'conversation-forked', title: 'Feature branch', }); await forkFlight.promise; }); expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id); }); it('does not write a pending fork into the Renderer store after switching projects', async () => { const forkFlight = deferred(); const secondProject = { ...project, id: 'project-2', name: 'Second project' }; const secondAgent = { ...agent, id: 'agent-project-2', name: 'Second builder' }; const secondConversation = { ...conversation, id: 'conversation-project-2', agentId: secondAgent.id, title: 'Second project conversation', }; 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.fork.mockReturnValue(forkFlight.promise); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const { codingWorkspaceStore } = await import('@/stores/coding-workspace'); const { codingConversationStore } = await import('@/stores/coding-conversations'); conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( withForkSource(createLocalConversationSnapshot( conversationId === secondConversation.id ? secondProject.id : project.id, conversationId === secondConversation.id ? secondConversation : conversation, ), `entry-${conversationId}`) )); render(); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' })); await waitFor(() => expect(conversationApi.fork) .toHaveBeenCalledWith(conversation.id, `entry-${conversation.id}`)); act(() => { codingWorkspaceStore.setState({ activeProjectId: secondProject.id, activeProject: secondProject, config: configForAgents([secondAgent]), conversations: [secondConversation], selectedAgentId: secondAgent.id, }); codingConversationStore.setState({ selectedConversationId: secondConversation.id }); }); await act(async () => { forkFlight.resolve({ ...conversation, id: 'conversation-forked', title: 'Old project fork' }); await forkFlight.promise; }); expect(codingWorkspaceStore.getState().conversations).toEqual([secondConversation]); expect(codingConversationStore.getState().selectedConversationId).toBe(secondConversation.id); }); it('only shows a metadata error on its originating Conversation', async () => { 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.recover.mockResolvedValue(undefined); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const { codingWorkspaceStore } = await import('@/stores/coding-workspace'); const { codingConversationStore } = await import('@/stores/coding-conversations'); conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( createLocalConversationSnapshot( project.id, conversationId === reviewerConversation.id ? reviewerConversation : conversation, ) )); render(); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); act(() => { codingWorkspaceStore.setState({ conversationErrorsByProjectId: { [project.id]: { [conversation.id]: 'metadata rejected' }, }, }); }); expect(await screen.findByText('metadata rejected')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(reviewerConversation.id)); expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument(); }); it('keeps project settings only in the Conversation list and removes legacy Header actions', async () => { 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.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, ) )); const onOpenProjectSettings = vi.fn(); render(); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); const header = screen.getByTestId('coding-conversation-header'); expect(within(header).queryByRole('button', { name: '创建分支' })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: /标为(已读|未读)/ })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: '归档' })).not.toBeInTheDocument(); expect(within(header).queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument(); expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: '项目设置' })); expect(onOpenProjectSettings).toHaveBeenCalledOnce(); }); it('remounts pending interaction state when switching Conversations', async () => { const respondFlight = 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.recover.mockResolvedValue(undefined); conversationApi.respond.mockReturnValue(respondFlight.promise); 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) => { const metadata = conversationId === reviewerConversation.id ? reviewerConversation : conversation; return { ...createLocalConversationSnapshot(project.id, metadata), pendingInteractions: [{ id: `interaction-${conversationId}`, conversationId, runId: `run-${conversationId}`, kind: 'confirm' as const, title: `Confirm ${conversationId}`, status: 'pending' as const, }], }; }); render(); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); fireEvent.click(await screen.findByRole('button', { name: '确认' })); await waitFor(() => expect(conversationApi.respond).toHaveBeenCalledWith(conversation.id, { interactionId: `interaction-${conversation.id}`, confirmed: true, })); fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(reviewerConversation.id)); expect(await screen.findByText(`Confirm ${reviewerConversation.id}`)).toBeInTheDocument(); expect(screen.getByRole('button', { name: '确认' })).toBeEnabled(); await act(async () => { respondFlight.resolve(); await respondFlight.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('reenables the same Conversation after a definite prompt rejection', 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 .mockRejectedValueOnce(new Error('所选模型当前不可用,请重新选择。')) .mockImplementationOnce(async (input: { conversationId: string; clientRequestId: string; mode: 'prompt'; }) => ({ accepted: true, conversationId: input.conversationId, clientRequestId: input.clientRequestId, runId: 'run-retry', mode: input.mode, })); 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(); const textbox = await screen.findByRole('textbox'); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); fireEvent.change(textbox, { target: { value: 'First prompt' } }); await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => expect(textbox).toHaveValue('First prompt')); expect(await screen.findByText('所选模型当前不可用,请重新选择。')).toBeInTheDocument(); expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.error) .toBeNull(); expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument(); fireEvent.change(textbox, { target: { value: 'Retry prompt' } }); await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2)); }); it('clears a confirmation uncertainty automatically after the authoritative run settles', 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.mockRejectedValue(new AppError( 'UNKNOWN', '请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。', undefined, { backendCode: 'CODING_REQUEST_UNCERTAIN' }, )); 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(); const textbox = await screen.findByRole('textbox'); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); fireEvent.change(textbox, { target: { value: 'Slow prompt' } }); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument(); expect(textbox).toHaveValue('Slow prompt'); act(() => codingConversationStore.getState().applyPatchBatchEvent({ type: 'patch-batch', conversationId: conversation.id, workerGeneration: 0, fromSeq: 1, toSeq: 1, items: [{ seq: 1, at: 1_000, runId: 'run-uncertain', patch: { op: 'run.state', run: { status: 'running', runId: 'run-uncertain', mode: 'prompt', error: { code: 'CODING_REQUEST_UNCERTAIN', message: '请求确认延迟,可能仍在执行。', recoverable: true, }, }, }, }], })); await waitFor(() => expect(screen.getByRole('button', { name: /模型与思考设置/ })).toBeDisabled()); act(() => codingConversationStore.getState().applyPatchBatchEvent({ type: 'patch-batch', conversationId: conversation.id, workerGeneration: 0, fromSeq: 2, toSeq: 2, items: [{ seq: 2, at: 12_000, runId: 'run-uncertain', patch: { op: 'run.state', run: { status: 'idle', runId: 'run-uncertain', mode: 'prompt', settledAt: 12_000, terminalReason: 'completed', }, }, }], })); await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/)) .not.toBeInTheDocument()); expect(screen.queryByText(/本地编程运行时暂时不可用/)).not.toBeInTheDocument(); expect(textbox).toHaveValue('Slow prompt'); expect(screen.getByRole('button', { name: '发送' })).toBeEnabled(); }); it('refreshes the authoritative Snapshot after abort when the terminal SSE patch was missed', 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.abort.mockResolvedValue(undefined); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const runningSnapshot: ConversationSnapshot = { ...createLocalConversationSnapshot(project.id, conversation), worker: { status: 'ready', generation: 1 }, run: { status: 'running', runId: 'run-stale', mode: 'prompt' }, cursor: { workerGeneration: 1, seq: 7 }, }; const settledSnapshot: ConversationSnapshot = { ...runningSnapshot, run: { status: 'idle', runId: 'run-stale', mode: 'prompt', settledAt: 12_000, terminalReason: 'completed', }, cursor: { workerGeneration: 1, seq: 8 }, }; conversationApi.snapshot .mockResolvedValueOnce(runningSnapshot) .mockResolvedValueOnce(settledSnapshot); render(); const abortButton = await screen.findByRole('button', { name: '中止生成' }); fireEvent.click(abortButton); await waitFor(() => expect(conversationApi.abort).toHaveBeenCalledWith(conversation.id)); await waitFor(() => expect(conversationApi.snapshot).toHaveBeenCalledTimes(2)); expect(await screen.findByRole('button', { name: '发送' })).toBeInTheDocument(); }); 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('keeps attachment removal locked until every upload settles after one fails', async () => { const upload = deferred<{ attachmentId: string; mime: string; byteLength: number }>(); 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, })); attachmentApi.upload.mockImplementation((file: File) => ( file.name === 'failed.png' ? Promise.reject(new Error('图片附件无效。')) : upload.promise )); 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 failed = new File(['failed'], 'failed.png', { type: 'image/png' }); const held = new File(['held'], 'held.png', { type: 'image/png' }); fireEvent.change(screen.getByTestId('coding-file-attachment-input'), { target: { files: [failed, held] }, }); fireEvent.click(await screen.findByRole('button', { name: '发送' })); await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(2)); const remove = screen.getByRole('button', { name: '移除图片 held.png' }); expect(remove).toBeDisabled(); fireEvent.click(remove); expect(screen.getByAltText('held.png')).toBeInTheDocument(); expect(screen.queryByText('图片附件无效。')).not.toBeInTheDocument(); upload.resolve({ attachmentId: 'attachment-held', mime: 'image/png', byteLength: 5 }); expect(await screen.findByText('图片附件无效。')).toBeInTheDocument(); expect(remove).toBeEnabled(); expect(conversationApi.submit).not.toHaveBeenCalled(); }); it('keeps next-message images out of a submission while its 202 response is pending', async () => { const acceptanceFlight = deferred<{ accepted: true; conversationId: string; clientRequestId: string; runId: string; mode: 'prompt'; }>(); 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.mockReturnValue(acceptanceFlight.promise); attachmentApi.upload.mockImplementation(async (file: File) => ({ 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(); const textbox = await screen.findByRole('textbox'); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) .toBe(conversation.id)); const first = new File(['first'], 'first.png', { type: 'image/png' }); const next = new File(['next'], 'next.png', { type: 'image/png' }); fireEvent.change(screen.getByTestId('coding-file-attachment-input'), { target: { files: [first] }, }); fireEvent.click(await screen.findByRole('button', { name: '发送' })); await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce()); expect(screen.getByTestId('coding-file-attachment-input')).toBeDisabled(); fireEvent.paste(textbox, { clipboardData: { files: [next] } }); expect(screen.queryByAltText('next.png')).not.toBeInTheDocument(); expect(attachmentApi.upload).toHaveBeenCalledOnce(); acceptanceFlight.resolve({ accepted: true, conversationId: conversation.id, clientRequestId: 'request-1', runId: 'run-1', mode: 'prompt', }); await waitFor(() => expect(screen.getByTestId('coding-file-attachment-input')).toBeEnabled()); fireEvent.paste(textbox, { clipboardData: { files: [next] } }); expect(await screen.findByAltText('next.png')).toBeInTheDocument(); expect(attachmentApi.upload).toHaveBeenCalledOnce(); }); it('keeps attachment validation failures local and reuses successful uploads on retry', 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, })); attachmentApi.upload.mockImplementation(async (file: File) => { if (file.name === 'invalid.png') throw new Error('图片附件无效。'); return { 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 valid = new File(['valid'], 'valid.png', { type: 'image/png' }); const invalid = new File(['invalid'], 'invalid.png', { type: 'image/png' }); fireEvent.change(screen.getByTestId('coding-file-attachment-input'), { target: { files: [valid, invalid] }, }); fireEvent.click(await screen.findByRole('button', { name: '发送' })); expect(await screen.findByText('图片附件无效。')).toBeInTheDocument(); expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.error) .toBeNull(); expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '发送' })).toBeEnabled(); fireEvent.click(screen.getByRole('button', { name: '移除图片 invalid.png' })); await waitFor(() => expect(screen.queryByText('图片附件无效。')).not.toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce()); expect(attachmentApi.upload.mock.calls.filter(([file]) => file.name === 'valid.png')) .toHaveLength(1); expect(attachmentApi.upload).toHaveBeenCalledTimes(2); expect(conversationApi.recover).not.toHaveBeenCalled(); }); 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(); }); });