1148 lines
51 KiB
TypeScript
1148 lines
51 KiB
TypeScript
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 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('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(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')).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('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();
|
|
});
|
|
});
|