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