import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; import type { ConversationSnapshot } from '@/types/coding-conversation'; const interactionApi = vi.hoisted(() => ({ respond: vi.fn(), abort: vi.fn(), compact: vi.fn(), model: vi.fn(), thinking: vi.fn(), diagnostics: vi.fn(), })); const inspectorApi = vi.hoisted(() => ({ changes: vi.fn(), commands: vi.fn(), content: vi.fn(), files: vi.fn(), find: vi.fn(), skills: vi.fn(), })); vi.mock('@/lib/coding-conversations', async (importOriginal) => ({ ...await importOriginal(), respondCodingConversationInteraction: interactionApi.respond, abortCodingConversation: interactionApi.abort, compactCodingConversation: interactionApi.compact, setCodingConversationModel: interactionApi.model, setCodingConversationThinking: interactionApi.thinking, getCodingRuntimeDiagnostics: interactionApi.diagnostics, })); vi.mock('@/lib/coding-product-tools', () => ({ getCodingConversationChanges: inspectorApi.changes, getCodingConversationCommands: inspectorApi.commands, getCodingFileContent: inspectorApi.content, getCodingFileStatus: inspectorApi.files, findCodingFiles: inspectorApi.find, getCodingSkills: inspectorApi.skills, })); describe('PI-130 feature-complete Coding UI', () => { it('builds vendor-neutral Conversation model choices without runtime keys', async () => { const { buildCodingModelOptions, parseCodingModelKey } = await import( '@/lib/coding-model-options' ); const account = { id: 'account-1', vendorId: 'custom', label: 'Work account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b', 'model-a'], enabled: true, isDefault: true, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', } satisfies ProviderAccount; const vendor = { id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo; const options = buildCodingModelOptions([account], [vendor]); expect(options.map((option) => option.modelId)).toEqual(['model-a', 'model-b']); expect(options[0].label).toBe('Work account · Custom vendor / model-a'); expect(parseCodingModelKey(options[0].key)).toEqual({ accountId: 'account-1', modelId: 'model-a' }); expect(JSON.stringify(options)).not.toMatch(/opencode|providerID/i); }); it('exposes steer/follow-up modes and queue waiting state while a turn runs', async () => { const onModeChange = vi.fn(); const onSubmit = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); expect(screen.getByText('1 条消息正在等待;本轮结算后会继续处理。')).toBeInTheDocument(); expect(screen.getByText('第 1 位 · 下一轮')).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), { target: { value: 'steer' } }); expect(onModeChange).toHaveBeenCalledWith('steer'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(onSubmit).toHaveBeenCalledOnce(); }); it('unlocks editing and offers recovery after the local Agent exits', async () => { const onRecover = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); expect(screen.getByRole('textbox')).toBeEnabled(); expect(screen.getByRole('alert')).toHaveTextContent('本地 Agent 已中断,原请求未自动重发。'); expect(screen.queryByText('当前对话正在处理。')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试' })); expect(onRecover).toHaveBeenCalledOnce(); }); it('answers product interactions and explains stale responses', async () => { interactionApi.respond.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('409 stale')); const { CodingInteractionPanel } = await import('@/pages/Chat/CodingInteractionPanel'); const onSettled = vi.fn(); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '方案 A' })); await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', { interactionId: 'interaction-select', optionId: 'option-a', })); expect(onSettled).toHaveBeenCalledOnce(); rerender( , ); fireEvent.change(screen.getByRole('textbox', { name: '输入名称的回答' }), { target: { value: 'Makelore' } }); fireEvent.click(screen.getByRole('button', { name: '提交回答' })); expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument(); }); it('keeps model, thinking, metadata, and fork controls on the selected idle Conversation', async () => { const { useProviderStore } = await import('@/stores/providers'); useProviderStore.setState({ accounts: [{ id: 'account-1', vendorId: 'custom', label: 'Work account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b'], enabled: true, isDefault: true, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', }], vendors: [{ id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo], }); interactionApi.abort.mockResolvedValue(undefined); interactionApi.model.mockResolvedValue({ model: null, modelResolution: 'required' }); interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' }); const callbacks = { rename: vi.fn(async () => undefined), archive: vi.fn(async () => undefined), unread: vi.fn(async () => undefined), fork: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), recover: vi.fn(async () => undefined), inspector: vi.fn(), }; const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); render( , ); fireEvent.change(screen.getByRole('combobox', { name: '当前对话模型' }), { target: { value: JSON.stringify(['account-1', 'model-b']) }, }); await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', { accountId: 'account-1', modelId: 'model-b', thinkingLevel: 'high', })); await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce()); const thinkingSelect = screen.getByRole('combobox', { name: '当前对话思考级别' }); expect(within(thinkingSelect).getAllByRole('option')).toHaveLength(1); expect(within(thinkingSelect).getByRole('option', { name: '高思考' })).toBeInTheDocument(); expect(within(thinkingSelect).queryByRole('option', { name: '关闭思考' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '创建分支' })); await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce()); expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument(); }); it('clears a compact confirmation uncertainty when the authoritative run settles', async () => { interactionApi.compact.mockRejectedValueOnce(Object.assign( new Error('请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。'), { details: { backendCode: 'CODING_REQUEST_UNCERTAIN' } }, )); const conversation = { id: 'conversation-compact-uncertain', agentId: 'agent-1', title: 'Compact uncertainty', archivedAt: null, unread: false, createdAt: '2026-08-25T00:00:00.000Z', updatedAt: '2026-08-25T00:00:00.000Z', model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const }, modelResolution: 'resolved' as const, }; const snapshot = (run: ConversationSnapshot['run'], seq = 1): ConversationSnapshot => ({ schemaVersion: 1, conversation: { id: conversation.id, projectId: 'project-1', agentId: conversation.agentId, title: conversation.title, model: { model: conversation.model, modelResolution: 'resolved' }, }, nodes: [], run, queue: { items: [] }, context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' }, pendingInteractions: [], worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq }, }); const callbacks = { onRename: vi.fn(async () => undefined), onArchive: vi.fn(async () => undefined), onToggleUnread: vi.fn(async () => undefined), onFork: vi.fn(async () => undefined), onRefresh: vi.fn(async () => undefined), onRecover: vi.fn(async () => undefined), onOpenInspector: vi.fn(), }; const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '整理上下文' })); expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument(); rerender( , ); expect(screen.getByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument(); rerender( , ); await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/)) .not.toBeInTheDocument()); }); it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => { interactionApi.abort.mockResolvedValue(undefined); const recover = vi.fn(async () => undefined); const fork = vi.fn(async () => undefined); const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); render( undefined)} onArchive={vi.fn(async () => undefined)} onToggleUnread={vi.fn(async () => undefined)} onFork={fork} onRefresh={vi.fn(async () => undefined)} onRecover={recover} onOpenInspector={vi.fn()} />, ); expect(screen.getByRole('combobox', { name: '当前对话模型' })).toBeDisabled(); expect(screen.getByRole('combobox', { name: '当前对话思考级别' })).toBeDisabled(); expect(screen.getByRole('button', { name: '整理上下文' })).toBeDisabled(); expect(screen.getByRole('button', { name: '创建分支' })).toBeDisabled(); expect(screen.queryByText('本地编程运行时暂时不可用')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '中止' })); await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-uncertain')); await waitFor(() => expect(screen.getByRole('button', { name: '恢复' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '恢复' })); await waitFor(() => expect(recover).toHaveBeenCalledOnce()); expect(fork).not.toHaveBeenCalled(); }); it('does not let an old tools load overwrite the newly selected Conversation inspector', async () => { let resolveOld!: (value: { commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }) => void; const oldCommands = new Promise<{ commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }>((resolve) => { resolveOld = resolve; }); inspectorApi.changes.mockResolvedValue({ changes: null }); inspectorApi.files.mockResolvedValue({ files: [] }); inspectorApi.skills.mockResolvedValue({ skills: [] }); inspectorApi.commands.mockImplementation((conversationId: string) => ( conversationId === 'conversation-old' ? oldCommands : Promise.resolve({ commands: [{ name: 'new-command', title: 'New command', description: 'New', source: 'makelore' as const }] }) )); interactionApi.diagnostics.mockResolvedValue({ revision: { provider: 1, resources: 1 }, workers: [] }); const { CodingWorkspaceInspector } = await import('@/pages/Chat/CodingWorkspaceInspector'); const view = render( , ); await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-old')); view.rerender( , ); await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-new')); await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()); const commandsTab = screen.getByRole('tab', { name: '命令' }); fireEvent.mouseDown(commandsTab, { button: 0, ctrlKey: false }); await waitFor(() => expect(commandsTab).toHaveAttribute('aria-selected', 'true')); expect(await screen.findByRole('button', { name: /\/new-command/ })).toBeInTheDocument(); resolveOld({ commands: [{ name: 'old-command', title: 'Old command', description: 'Old', source: 'makelore' }] }); await oldCommands; await waitFor(() => expect(screen.queryByRole('button', { name: /\/old-command/ })).not.toBeInTheDocument()); expect(screen.getByRole('button', { name: /\/new-command/ })).toBeInTheDocument(); }); });