Files
makelore/tests/unit/coding-chat-panel.test.tsx

1409 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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 navigate = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async (importOriginal) => ({ ...await importOriginal<typeof import('react-router-dom')>(), useNavigate: () => navigate }));
class FakeEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly close = vi.fn();
addEventListener = vi.fn();
}
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 secondProject: CodingProjectSummary = {
...project,
id: 'project-2',
name: 'Second local project',
lastOpenedAt: '2026-08-22T00: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() }));
const hostEventApi = vi.hoisted(() => ({ subscribe: 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<typeof import('@/lib/coding-attachments')>(),
uploadCodingAttachment: attachmentApi.upload,
}));
vi.mock('@/lib/host-events', () => ({
subscribeHostEvent: hostEventApi.subscribe,
}));
describe('CodingChatPanel first Conversation', () => {
beforeEach(() => {
hostEventApi.subscribe.mockReturnValue(vi.fn());
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('continues a failed operation using its history without sending or clearing the current draft', 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);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const failed = createLocalConversationSnapshot(project.id, conversation);
failed.run = { status: 'error', terminalReason: 'failed' };
failed.nodes = [{ kind: 'message', id: 'failed', role: 'assistant', status: 'error', blocks: [] }];
conversationApi.snapshot.mockResolvedValue(failed);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit.mockImplementation(async (input) => ({ accepted: true, conversationId: conversation.id, clientRequestId: input.clientRequestId, runId: 'resume-run', mode: 'prompt' }));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel />);
const composer = within(await screen.findByTestId('coding-message-composer')).getByRole('textbox');
fireEvent.change(composer, { target: { value: '这段还没有写完,不要发送' } });
fireEvent.click(await screen.findByRole('button', { name: '帮我继续' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(1));
expect(conversationApi.recover).toHaveBeenCalledWith(conversation.id);
expect(conversationApi.submit).toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining('已完成的步骤不要重复执行'), attachments: [] }));
expect(composer).toHaveValue('这段还没有写完,不要发送');
});
it.each([true, false])('only resumes the matching login account and project (same=%s)', async (same) => {
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);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { useAuthStore } = await import('@/stores/auth');
const { codingRecoveryAccount } = await import('@/lib/coding-login-recovery');
const user = { username: 'alice', userId: 'a1', tenantId: 't1', deptId: null, authorities: [] };
useAuthStore.setState({ user });
const workApi = await import('@/lib/coding-work-preview');
const ensure = vi.spyOn(workApi, 'ensureWorkPreview').mockResolvedValue({ status: 'ready' });
const onLoginRecoveryConsumed = vi.fn();
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel loginRecovery={{ id: 'return-1', projectId: project.id, conversationId: conversation.id, work: true, account: same ? codingRecoveryAccount(user) : 'another-account' }} onLoginRecoveryConsumed={onLoginRecoveryConsumed} />);
await screen.findByTestId('coding-message-composer');
if (same) {
await waitFor(() => expect(ensure).toHaveBeenCalledTimes(1));
expect(screen.getByRole('tab', { name: '作品', exact: true })).toHaveAttribute('aria-selected', 'true');
expect(onLoginRecoveryConsumed).toHaveBeenCalledTimes(1);
} else {
expect(screen.getByRole('tab', { name: '操作对话' })).toHaveAttribute('aria-selected', 'true');
expect(ensure).not.toHaveBeenCalled();
}
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('keeps operation and distributed-agent drafts separate across tabs and reopening', async () => {
localStorage.clear();
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);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { teacherApi } = await import('@/lib/coding-teacher');
vi.spyOn(teacherApi, 'config').mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: null });
vi.spyOn(teacherApi, 'list').mockResolvedValue({ items: [], lastSelectedTopicId: null });
const consultationSend = vi.spyOn(teacherApi, 'send');
const consultationCreate = vi.spyOn(teacherApi, 'create');
const browserApi = await import('@/lib/agent-browser');
const closedBrowser = {
browserId: null, projectId: project.id, projectPath: null, state: 'closed' as const,
generation: 0, url: '', title: '', visible: false, bounds: null,
canGoBack: false, canGoForward: false, eventCursor: 0,
};
vi.spyOn(browserApi, 'getAgentBrowserState').mockResolvedValue(closedBrowser);
vi.spyOn(browserApi, 'closeAgentBrowser').mockResolvedValue(closedBrowser);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel />);
const composer = await screen.findByTestId('coding-message-composer');
const operationInput = within(composer).getByRole('textbox');
fireEvent.change(operationInput, { target: { value: '我想先自己试一试' } });
const chatTab = screen.getByRole('tab', { name: '操作对话' });
const workTab = screen.getByRole('tab', { name: '作品', exact: true });
expect(chatTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(chatTab, { key: 'ArrowRight' });
expect(workTab).toHaveFocus();
expect(workTab).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel', { name: '作品', exact: true })).toBeVisible();
expect(operationInput).not.toBeVisible();
fireEvent.keyDown(workTab, { key: 'ArrowLeft' });
expect(chatTab).toHaveFocus();
expect(operationInput).toBeVisible();
expect(operationInput).toHaveValue('我想先自己试一试');
fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }));
const teacherInput = await screen.findByRole('textbox', { name: '向智能体提问' });
expect(workTab).toHaveAttribute('aria-selected', 'true');
expect(operationInput).not.toBeVisible();
expect(operationInput).toHaveValue('我想先自己试一试');
await waitFor(() => expect(teacherInput).toBeEnabled());
fireEvent.change(teacherInput, { target: { value: '怎样判断规则清不清楚?' } });
fireEvent.click(chatTab);
expect(operationInput).toBeVisible();
expect(teacherInput).toHaveValue('怎样判断规则清不清楚?');
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
expect(screen.queryByRole('button', { name: '朋友', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }));
expect(screen.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('怎样判断规则清不清楚?');
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-expanded', 'false');
expect(consultationCreate).not.toHaveBeenCalled();
expect(consultationSend).not.toHaveBeenCalled();
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('archives the last conversation without creating another, then restores the same running conversation', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
projectApi.patch.mockImplementation(async (_id, patch) => ({
...conversation, archivedAt: patch.archived ? '2026-09-20T00:00:00Z' : null,
}));
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue({ ...createLocalConversationSnapshot(project.id, conversation),
run: { status: 'running', runId: 'run-1' } });
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<CodingChatPanel />);
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
fireEvent.keyDown(menu, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' }));
await screen.findByTestId('coding-conversation-pending');
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
await screen.findByText('此对话已归档,任务仍在运行。');
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '恢复对话' }));
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false });
});
it('renames an unselected conversation from its menu without preparing that conversation', async () => {
const other = { ...conversation, id: 'unselected', title: '旧标题', updatedAt: '2025-01-01T00:00:00Z' };
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation, other]);
projectApi.patch.mockImplementation(async (_id, patch) => ({ ...other, ...patch }));
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
render(<CodingChatPanel />);
fireEvent.keyDown(await screen.findByRole('button', { name: '对话操作:旧标题' }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '重命名' }));
const input = screen.getByRole('textbox', { name: '对话标题' });
expect(input).toHaveValue('旧标题');
expect(input).toHaveAttribute('maxlength', '200');
fireEvent.change(input, { target: { value: ' 手动新标题 ' } });
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(projectApi.patch).toHaveBeenCalledWith(other.id, { title: '手动新标题' });
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id);
});
it('keeps a project with no Agent accessible and offers optional Agent setup', async () => {
const openProjectSettings = vi.fn();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({
project,
config: { ...configForAgents([]), initialized: false },
});
projectApi.conversations.mockResolvedValue([]);
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
render(<CodingChatPanel onOpenProjectSettings={openProjectSettings} />);
expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好');
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(within(screen.getByTestId('coding-chat-empty-agent')).getByRole('button', { name: '项目设置' }));
expect(openProjectSettings).toHaveBeenCalledOnce();
});
it('shows a branded create CTA and horizontal existing-project cards before a project is selected', async () => {
projectApi.list.mockResolvedValue({ projects: [project, secondProject], activeProjectId: null });
const onCreateProject = vi.fn();
const onOpenProject = vi.fn();
const onOpenProjectSettings = vi.fn();
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
render(
<CodingChatPanel
onCreateProject={onCreateProject}
onOpenProject={onOpenProject}
onOpenProjectSettings={onOpenProjectSettings}
/>,
);
expect(await screen.findByTestId('coding-chat-empty-project')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '新增项目' })).toBeInTheDocument();
expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Local project');
expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Second local project');
expect(screen.queryByText('已有项目')).not.toBeInTheDocument();
expect(screen.queryByText('选择一个项目继续')).not.toBeInTheDocument();
expect(screen.queryByTestId('coding-project-starter')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '项目与插件设置' })).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('coding-start-project-button'));
expect(onCreateProject).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '进入项目 Second local project' }));
expect(onOpenProject).toHaveBeenCalledWith(secondProject.id);
expect(onOpenProjectSettings).not.toHaveBeenCalled();
});
it('keeps an empty project editable without creating a conversation until first send', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([]);
projectApi.create.mockResolvedValue(conversation);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.snapshot).not.toHaveBeenCalled();
fireEvent.change(textbox, {target:{value:'First prompt'}});
fireEvent.click(screen.getByRole('button',{name:'发送'}));
await waitFor(()=>expect(projectApi.create).toHaveBeenCalledOnce());
});
it('rehydrates a preselected stale Conversation when the programming view mounts', 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);
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
const localSnapshot = createLocalConversationSnapshot(project.id, conversation);
codingWorkspaceStore.setState({
activeProjectId: project.id,
activeProject: project,
config,
conversations: [conversation],
selectedAgentId: agent.id,
});
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: conversation.id,
workerGeneration: 1,
seq: 4,
snapshot: {
...localSnapshot,
run: {
status: 'running',
runId: 'run-stale',
mode: 'prompt',
startedAt: 1_000,
},
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 4 },
},
});
codingConversationStore.setState({ selectedConversationId: conversation.id });
conversationApi.snapshot.mockResolvedValue({
...localSnapshot,
run: {
status: 'idle',
runId: 'run-stale',
mode: 'prompt',
startedAt: 1_000,
settledAt: 2_000,
terminalReason: 'aborted',
},
worker: { status: 'stopped', generation: 1 },
cursor: { workerGeneration: 1, seq: 5 },
});
render(<CodingChatPanel />);
await waitFor(() => expect(
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run,
).toMatchObject({ status: 'idle', terminalReason: 'aborted' }));
expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id);
expect(screen.queryByText('处理中')).not.toBeInTheDocument();
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('rehydrates a stale running Conversation after lifecycle sleep and window focus', async () => {
let sleepHandler: (() => void) | null = null;
hostEventApi.subscribe.mockImplementation((eventName: string, handler: () => void) => {
if (eventName === 'lifecycle:sleep') sleepHandler = handler;
return vi.fn();
});
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
projectApi.patch.mockResolvedValue(conversation);
const firstSource = new FakeEventSource();
const resumedSource = new FakeEventSource();
conversationApi.events
.mockResolvedValueOnce(firstSource as unknown as EventSource)
.mockResolvedValueOnce(resumedSource as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const localSnapshot = createLocalConversationSnapshot(project.id, conversation);
const runningSnapshot: ConversationSnapshot = {
...localSnapshot,
run: {
status: 'running',
runId: 'run-stale',
mode: 'prompt',
startedAt: 1_000,
},
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 4 },
};
const terminalSnapshot: ConversationSnapshot = {
...runningSnapshot,
run: {
status: 'idle',
runId: 'run-stale',
mode: 'prompt',
startedAt: 1_000,
settledAt: 2_000,
terminalReason: 'aborted',
},
worker: { status: 'stopped', generation: 1 },
cursor: { workerGeneration: 1, seq: 5 },
};
conversationApi.snapshot.mockResolvedValue(runningSnapshot);
render(<CodingChatPanel />);
await waitFor(() => expect(
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status,
).toBe('running'));
expect(screen.getByLabelText('对话正在运行')).toBeInTheDocument();
const snapshotCallsBeforeSleep = conversationApi.snapshot.mock.calls.length;
conversationApi.snapshot.mockResolvedValue(terminalSnapshot);
act(() => sleepHandler?.());
expect(firstSource.close).toHaveBeenCalledOnce();
act(() => window.dispatchEvent(new Event('focus')));
await waitFor(() => expect(
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run,
).toMatchObject({ status: 'idle', terminalReason: 'aborted' }));
expect(conversationApi.snapshot.mock.calls.length).toBeGreaterThan(snapshotCallsBeforeSleep);
expect(conversationApi.events).toHaveBeenCalledTimes(2);
expect(screen.queryByText('处理中')).not.toBeInTheDocument();
expect(conversationApi.submit).not.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('lists all project conversations without Agent navigation', 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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
const list = await screen.findByRole('group', {name:'项目会话'});
expect(within(list).getByRole('button',{name:conversation.title})).toBeVisible();
expect(within(list).getByRole('button',{name:reviewerConversation.title})).toBeVisible();
expect(screen.queryByRole('button',{name:agent.name})).not.toBeInTheDocument();
fireEvent.click(within(list).getByRole('button',{name:reviewerConversation.title}));
await waitFor(()=>expect(within(list).getByRole('button',{name:reviewerConversation.title})).toHaveAttribute('aria-current','page'));
});
it('does not let a pending message fork steal selection after a Conversation switch', async () => {
const forkFlight = deferred<CodingConversationMetadata>();
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel />);
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: reviewerConversation.title }));
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<CodingConversationMetadata>();
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel />);
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel />);
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: reviewerConversation.title }));
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel onOpenProjectSettings={onOpenProjectSettings} />);
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(navigate).toHaveBeenCalledWith('/project-config');
});
it('remounts pending interaction state when switching Conversations', async () => {
const respondFlight = deferred<void>();
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel />);
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: reviewerConversation.title }));
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<never>();
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel />);
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: reviewerConversation.title }));
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
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(<CodingChatPanel />);
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<ReturnType<typeof deferred<{
attachmentId: string;
mime: string;
byteLength: number;
}>>> = [];
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
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', { name: /^image-\d+\.png$/u })).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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
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: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
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.each([
['follow-up', 'completed'],
['follow-up', 'aborted'],
['steer', 'completed'],
['steer', 'aborted'],
] as const)('sends again after %s and %s settlement', async (mode, terminalReason) => {
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.submit.mockImplementation(async (input) => ({
accepted: true,
conversationId: input.conversationId,
clientRequestId: input.clientRequestId,
runId: 'run-1',
mode: input.mode,
}));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const runningSnapshot: ConversationSnapshot = {
...createLocalConversationSnapshot(project.id, conversation),
worker: { status: 'ready', generation: 1 },
run: { status: 'running', runId: 'run-1', mode: 'prompt' },
cursor: { workerGeneration: 1, seq: 1 },
};
conversationApi.snapshot.mockResolvedValue(runningSnapshot);
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await screen.findByRole('button', { name: '中止生成' });
fireEvent.change(textbox, { target: { value: '排队的消息' } });
fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), {
target: { value: mode },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.getByTestId('coding-file-attachment-input')).toBeEnabled());
expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.toBeInTheDocument();
act(() => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: conversation.id,
workerGeneration: 1,
seq: 2,
snapshot: {
...runningSnapshot,
cursor: { workerGeneration: 1, seq: 2 },
run: { status: 'idle', runId: 'run-1', mode: 'prompt', terminalReason, settledAt: 2_000 },
},
}));
fireEvent.change(textbox, { target: { value: '继续' } });
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.toBeInTheDocument();
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' });
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2));
expect(conversationApi.submit).toHaveBeenLastCalledWith(expect.objectContaining({
conversationId: conversation.id,
mode: 'prompt',
text: '继续',
}));
});
it('keeps preparation feedback and preserves Enter versus Shift+Enter behavior', async () => {
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
const onSubmit = vi.fn();
render(
<CodingComposer
value="Ready"
editable
canSend
preparing
recovering={false}
runStatus="preparing"
mode="prompt"
queue={{ items: [] }}
error={null}
recoverableError={false}
attachments={[]}
submitting={false}
placeholder="Message"
onChange={vi.fn()}
onModeChange={vi.fn()}
onSubmit={onSubmit}
onRecover={vi.fn()}
onAddFiles={vi.fn()}
onRemoveAttachment={vi.fn()}
/>,
);
expect(screen.getByText('正在准备本地 Agent,输入框仍可编辑。')).toBeInTheDocument();
expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.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();
});
});