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

1323 lines
61 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 runtimeSnapshot = deferred<ConversationSnapshot>();
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 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('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 } = await import('@/pages/Chat/CodingChatPanel');
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.findByText('没有最近会话,可新建对话或查看归档');
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '已归档1' }));
fireEvent.click(within(screen.getByTestId('coding-conversation-sidebar')).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 } = await import('@/pages/Chat/CodingChatPanel');
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 } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel onOpenProjectSettings={openProjectSettings} />);
expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好');
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(screen.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 } = await import('@/pages/Chat/CodingChatPanel');
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('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(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument();
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('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 } = await import('@/pages/Chat/CodingChatPanel');
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 } = await import('@/pages/Chat/CodingChatPanel');
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.getByText('处理中')).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('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
const slowCreate = deferred<CodingConversationMetadata>();
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(<CodingChatPanel />);
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(<CodingChatPanel />);
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<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 } = 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(<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: /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<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 } = 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(<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 } = 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(<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: /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(<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(onOpenProjectSettings).toHaveBeenCalledOnce();
});
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 } = 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(<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: /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<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 } = 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(<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: /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(<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 } = 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(<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 } = 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(<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 } = 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(<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 } = 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(<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 } = 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(<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 } = 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(<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 } = await import('@/pages/Chat/CodingChatPanel');
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());
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('shows the 202 acceptance 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}
acceptedCount={1}
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('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();
});
});