723 lines
27 KiB
TypeScript
723 lines
27 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(),
|
||
model: vi.fn(),
|
||
thinking: vi.fn(),
|
||
}));
|
||
|
||
const changesApi = vi.hoisted(() => ({
|
||
get: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
|
||
...await importOriginal<typeof import('@/lib/coding-conversations')>(),
|
||
respondCodingConversationInteraction: interactionApi.respond,
|
||
abortCodingConversation: interactionApi.abort,
|
||
setCodingConversationModel: interactionApi.model,
|
||
setCodingConversationThinking: interactionApi.thinking,
|
||
}));
|
||
|
||
vi.mock('@/lib/coding-product-tools', async (importOriginal) => ({
|
||
...await importOriginal<typeof import('@/lib/coding-product-tools')>(),
|
||
getCodingConversationChanges: changesApi.get,
|
||
}));
|
||
|
||
describe('PI-130 feature-complete Coding UI', () => {
|
||
it('summarizes changed files above the composer and expands their diff', async () => {
|
||
changesApi.get.mockResolvedValueOnce({
|
||
changes: {
|
||
conversationId: 'conversation-changes',
|
||
runId: 'run-changes',
|
||
git: true,
|
||
baselineHead: 'head-changes',
|
||
files: [
|
||
{
|
||
path: 'src/app.ts',
|
||
status: 'modified',
|
||
diff: '--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1,2 @@\n-old\n+new\n+extra',
|
||
},
|
||
{
|
||
path: 'src/new.ts',
|
||
status: 'untracked',
|
||
preview: 'export const created = true;\n',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
const snapshot: ConversationSnapshot = {
|
||
schemaVersion: 1,
|
||
conversation: {
|
||
id: 'conversation-changes',
|
||
projectId: 'project-1',
|
||
agentId: 'agent-1',
|
||
title: 'Changes',
|
||
model: { model: null, modelResolution: 'required' },
|
||
},
|
||
nodes: [{
|
||
kind: 'tool',
|
||
id: 'tool-changes',
|
||
toolCallId: 'call-changes',
|
||
toolName: 'changed_file',
|
||
title: '记录文件更改',
|
||
inputText: '',
|
||
status: 'complete',
|
||
output: [],
|
||
details: { schema: 'changed-file.v1', paths: ['src/app.ts', 'src/new.ts'] },
|
||
}],
|
||
run: {
|
||
status: 'idle',
|
||
runId: 'run-changes',
|
||
startedAt: 1_000,
|
||
settledAt: 2_000,
|
||
terminalReason: 'completed',
|
||
},
|
||
queue: { items: [] },
|
||
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
|
||
pendingInteractions: [],
|
||
worker: { status: 'ready', generation: 1 },
|
||
cursor: { workerGeneration: 1, seq: 1 },
|
||
};
|
||
const { CodingChangesSummary } = await import('@/pages/Chat/CodingChangesSummary');
|
||
render(<CodingChangesSummary conversationId="conversation-changes" snapshot={snapshot} />);
|
||
|
||
const summary = await screen.findByRole('button', { name: '查看 2 个文件的更改' });
|
||
expect(summary).toHaveTextContent('2 个文件已更改');
|
||
expect(summary).toHaveTextContent('+3');
|
||
expect(summary).toHaveTextContent('-1');
|
||
fireEvent.click(summary);
|
||
expect(screen.getByText('src/app.ts')).toBeVisible();
|
||
fireEvent.click(screen.getByText('src/app.ts').closest('summary')!);
|
||
expect(screen.getByText(/\+extra/)).toBeVisible();
|
||
expect(changesApi.get).toHaveBeenCalledWith('conversation-changes');
|
||
});
|
||
|
||
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(options[0]).toMatchObject({
|
||
availableThinkingLevels: null,
|
||
});
|
||
expect(parseCodingModelKey(options[0].key)).toEqual({ accountId: 'account-1', modelId: 'model-a' });
|
||
expect(JSON.stringify(options)).not.toMatch(/opencode|providerID/i);
|
||
});
|
||
|
||
it('projects only verified selectable thinking strengths for configured models', async () => {
|
||
const { buildCodingModelOptions } = await import('@/lib/coding-model-options');
|
||
const account = {
|
||
id: 'account-reasoning',
|
||
vendorId: 'custom',
|
||
label: 'Reasoning account',
|
||
authMode: 'api_key',
|
||
model: 'server-model',
|
||
fallbackModels: ['deepseek-v4-pro', 'qwen3.8-max', 'unknown-model'],
|
||
enabled: true,
|
||
isDefault: true,
|
||
metadata: {
|
||
worksSquareModelCapabilities: {
|
||
'server-model': {
|
||
reasoningEfforts: ['max', 'low'],
|
||
reasoningCanDisable: true,
|
||
},
|
||
},
|
||
},
|
||
createdAt: '2026-08-24T00:00:00.000Z',
|
||
updatedAt: '2026-08-24T00:00:00.000Z',
|
||
} satisfies ProviderAccount;
|
||
|
||
const options = buildCodingModelOptions([account], []);
|
||
|
||
expect(Object.fromEntries(options.map((option) => [option.modelId, option.availableThinkingLevels]))).toEqual({
|
||
'server-model': ['off', 'low', 'max'],
|
||
'deepseek-v4-pro': ['off', 'low', 'high', 'max'],
|
||
'qwen3.8-max': ['off', 'low', 'medium', 'high'],
|
||
'unknown-model': null,
|
||
});
|
||
});
|
||
|
||
it('shows the exact configured model name and its selectable thinking strengths', async () => {
|
||
const { ModelList } = await import('@/pages/ProjectConfiguration');
|
||
render(<ModelList models={[{
|
||
key: JSON.stringify(['account-a', 'qwen3.8-max']),
|
||
accountId: 'account-a',
|
||
modelId: 'qwen3.8-max',
|
||
label: '团队模型 · 模型广场 / qwen3.8-max',
|
||
availableThinkingLevels: ['off', 'low', 'medium', 'high'],
|
||
}]} />);
|
||
|
||
const card = screen.getByTestId(/model-card-/);
|
||
expect(card).toHaveTextContent('qwen3.8-max');
|
||
expect(card).toHaveTextContent('可选思考强度');
|
||
for (const level of ['关闭', '低', '中等', '高']) {
|
||
expect(within(card).getByText(level)).toBeVisible();
|
||
}
|
||
expect(card).not.toHaveTextContent('account-a');
|
||
expect(card).not.toHaveTextContent('团队模型');
|
||
expect(card).not.toHaveTextContent('已配置');
|
||
});
|
||
|
||
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('keeps Pi abort available in the restored composer while a turn streams', async () => {
|
||
const onAbort = vi.fn();
|
||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||
render(
|
||
<CodingComposer
|
||
value=""
|
||
editable
|
||
canSend={false}
|
||
preparing={false}
|
||
recovering={false}
|
||
runStatus="running"
|
||
mode="prompt"
|
||
queue={{ items: [] }}
|
||
error={null}
|
||
recoverableError={false}
|
||
acceptedCount={0}
|
||
attachments={[]}
|
||
submitting={false}
|
||
placeholder="Prompt"
|
||
onChange={vi.fn()}
|
||
onModeChange={vi.fn()}
|
||
onSubmit={vi.fn()}
|
||
onAbort={onAbort}
|
||
onRecover={vi.fn()}
|
||
onAddFiles={vi.fn()}
|
||
onRemoveAttachment={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '中止生成' }));
|
||
expect(onAbort).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('uses the custom title bar for the full-height Conversation columns', async () => {
|
||
const originalPlatform = window.electron.platform;
|
||
const { useSettingsStore } = await import('@/stores/settings');
|
||
const { CodingConversationSidebar } = await import('@/pages/Chat/CodingConversationSidebar');
|
||
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||
window.electron.platform = 'darwin';
|
||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||
|
||
render(
|
||
<>
|
||
<CodingConversationSidebar
|
||
projectName="Layout project"
|
||
agents={[]}
|
||
conversations={[]}
|
||
conversationSummaries={{}}
|
||
selectedAgentId={null}
|
||
selectedConversationId={null}
|
||
creatingAgentIds={{}}
|
||
onSelectAgent={vi.fn()}
|
||
onSelectConversation={vi.fn()}
|
||
onCreateConversation={vi.fn()}
|
||
onOpenProjectSettings={vi.fn()}
|
||
/>
|
||
<CodingConversationHeader
|
||
conversation={null}
|
||
snapshot={null}
|
||
onRename={vi.fn(async () => undefined)}
|
||
onAbort={vi.fn(async () => undefined)}
|
||
onRecover={vi.fn(async () => undefined)}
|
||
/>
|
||
</>,
|
||
);
|
||
|
||
expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveClass('fixed', 'top-0', 'h-10');
|
||
expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveStyle({ left: '0px' });
|
||
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveClass('fixed', 'top-0', 'h-10');
|
||
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '256px' });
|
||
expect(screen.queryByText('Pi 本地对话')).not.toBeInTheDocument();
|
||
expect(within(screen.getByTestId('coding-conversation-sidebar'))
|
||
.queryByTestId('coding-conversation-sidebar-header')).not.toBeInTheDocument();
|
||
|
||
window.electron.platform = originalPlatform;
|
||
useSettingsStore.setState({ sidebarCollapsed: false });
|
||
});
|
||
|
||
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('hides settled interactions, accepts a custom selection, 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-answered',
|
||
conversationId: 'conversation-1',
|
||
runId: 'run-1',
|
||
kind: 'confirm',
|
||
title: '已经回答的问题',
|
||
status: 'answered',
|
||
},
|
||
{
|
||
id: 'interaction-select',
|
||
conversationId: 'conversation-1',
|
||
runId: 'run-1',
|
||
kind: 'select',
|
||
title: '选择方案',
|
||
options: [{ id: 'option-a', label: '方案 A' }],
|
||
status: 'pending',
|
||
},
|
||
]}
|
||
onSettled={onSettled}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.queryByText('已经回答的问题')).not.toBeInTheDocument();
|
||
fireEvent.change(screen.getByRole('textbox', { name: '选择方案的其他回答' }), {
|
||
target: { value: ' 方案 C ' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
|
||
await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', {
|
||
interactionId: 'interaction-select',
|
||
value: '方案 C',
|
||
}));
|
||
expect(onSettled).toHaveBeenCalledOnce();
|
||
|
||
rerender(
|
||
<CodingInteractionPanel
|
||
conversationId="conversation-1"
|
||
interactions={[{
|
||
id: 'interaction-answered',
|
||
conversationId: 'conversation-1',
|
||
runId: 'run-1',
|
||
kind: 'confirm',
|
||
title: '已经回答的问题',
|
||
status: 'answered',
|
||
}]}
|
||
/>,
|
||
);
|
||
expect(screen.queryByTestId('coding-interactions')).not.toBeInTheDocument();
|
||
|
||
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 and thinking inside the composer while legacy metadata actions leave the title bar', 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),
|
||
abort: vi.fn(async () => undefined),
|
||
refresh: vi.fn(async () => undefined),
|
||
recover: vi.fn(async () => undefined),
|
||
};
|
||
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||
const 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' as const },
|
||
modelResolution: 'resolved' as const,
|
||
};
|
||
const snapshot = {
|
||
schemaVersion: 1 as const,
|
||
conversation: {
|
||
id: 'conversation-1',
|
||
projectId: 'project-1',
|
||
agentId: 'agent-1',
|
||
title: 'Feature UI',
|
||
model: {
|
||
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'high' as const },
|
||
modelResolution: 'resolved' as const,
|
||
availableThinkingLevels: ['off' as const, 'high' as const],
|
||
},
|
||
},
|
||
nodes: [],
|
||
run: { status: 'idle' as const },
|
||
queue: { items: [] },
|
||
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' as const },
|
||
pendingInteractions: [],
|
||
worker: { status: 'ready' as const, generation: 1 },
|
||
cursor: { workerGeneration: 1, seq: 1 },
|
||
};
|
||
render(
|
||
<>
|
||
<CodingConversationHeader
|
||
conversation={conversation}
|
||
snapshot={snapshot}
|
||
onRename={callbacks.rename}
|
||
onAbort={callbacks.abort}
|
||
onRecover={callbacks.recover}
|
||
/>
|
||
<CodingComposer
|
||
value=""
|
||
editable
|
||
canSend={false}
|
||
preparing={false}
|
||
recovering={false}
|
||
runStatus="idle"
|
||
mode="prompt"
|
||
queue={{ items: [] }}
|
||
error={null}
|
||
recoverableError={false}
|
||
acceptedCount={0}
|
||
attachments={[]}
|
||
submitting={false}
|
||
placeholder="Prompt"
|
||
conversation={conversation}
|
||
snapshot={snapshot}
|
||
onChange={vi.fn()}
|
||
onModeChange={vi.fn()}
|
||
onSubmit={vi.fn()}
|
||
onRecover={vi.fn()}
|
||
onAddFiles={vi.fn()}
|
||
onRemoveAttachment={vi.fn()}
|
||
onRefreshRuntime={callbacks.refresh}
|
||
/>
|
||
</>,
|
||
);
|
||
|
||
const composerRuntimeSlot = screen.getByTestId('coding-message-composer');
|
||
expect(within(composerRuntimeSlot).queryByRole('button', { name: '整理上下文' }))
|
||
.not.toBeInTheDocument();
|
||
const settingsTrigger = within(composerRuntimeSlot).getByRole('button', { name: /模型与思考设置/ });
|
||
fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false });
|
||
const modelRow = await screen.findByRole('menuitem', { name: '模型 model-a' });
|
||
fireEvent.click(modelRow);
|
||
fireEvent.click(await screen.findByRole('menuitemradio', { name: 'model-b' }));
|
||
await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', {
|
||
accountId: 'account-1',
|
||
modelId: 'model-b',
|
||
thinkingLevel: 'high',
|
||
}));
|
||
await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce());
|
||
fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false });
|
||
const thinkingRow = await screen.findByRole('menuitem', { name: '推理强度 高' });
|
||
fireEvent.click(thinkingRow);
|
||
expect(await screen.findAllByRole('menuitemradio')).toHaveLength(2);
|
||
expect(screen.getByRole('menuitemradio', { name: '高' })).toBeInTheDocument();
|
||
expect(screen.queryByRole('menuitemradio', { name: '中等' })).not.toBeInTheDocument();
|
||
fireEvent.click(screen.getByRole('menuitemradio', { name: '关闭' }));
|
||
await waitFor(() => expect(interactionApi.thinking).toHaveBeenCalledWith('conversation-1', 'off'));
|
||
await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledTimes(2));
|
||
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(within(header).queryByRole('button', { name: '打开编程工具' })).not.toBeInTheDocument();
|
||
expect(within(header).queryByText(/Pi ·/)).not.toBeInTheDocument();
|
||
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('labels a model that only reports off as having no adjustable thinking strength', async () => {
|
||
const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls');
|
||
const conversation = {
|
||
id: 'conversation-no-thinking',
|
||
agentId: 'agent-1',
|
||
title: 'No thinking controls',
|
||
archivedAt: null,
|
||
unread: false,
|
||
createdAt: '2026-08-28T00:00:00.000Z',
|
||
updatedAt: '2026-08-28T00:00:00.000Z',
|
||
model: { accountId: 'account-1', modelId: 'model-off-only', thinkingLevel: 'off' as const },
|
||
modelResolution: 'resolved' as const,
|
||
};
|
||
const snapshot: ConversationSnapshot = {
|
||
schemaVersion: 1,
|
||
conversation: {
|
||
id: conversation.id,
|
||
projectId: 'project-1',
|
||
agentId: conversation.agentId,
|
||
title: conversation.title,
|
||
model: {
|
||
model: conversation.model,
|
||
modelResolution: 'resolved',
|
||
availableThinkingLevels: ['off'],
|
||
},
|
||
},
|
||
nodes: [],
|
||
run: { status: 'idle' },
|
||
queue: { items: [] },
|
||
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
|
||
pendingInteractions: [],
|
||
worker: { status: 'ready', generation: 1 },
|
||
cursor: { workerGeneration: 1, seq: 0 },
|
||
};
|
||
|
||
render(
|
||
<CodingComposerRuntimeControls
|
||
conversation={conversation}
|
||
snapshot={snapshot}
|
||
onRefresh={vi.fn(async () => undefined)}
|
||
/>,
|
||
);
|
||
|
||
const settingsTrigger = screen.getByRole('button', {
|
||
name: '模型与思考设置:model-off-only,不可调',
|
||
});
|
||
expect(settingsTrigger).toHaveTextContent('不可调');
|
||
fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false });
|
||
expect(await screen.findByRole('menuitem', { name: '推理强度 不可调' })).toHaveAttribute(
|
||
'aria-disabled',
|
||
'true',
|
||
);
|
||
expect(screen.queryByRole('menu', { name: '选择推理强度' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('shows the native max thinking level as 最高', async () => {
|
||
const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls');
|
||
const conversation = {
|
||
id: 'conversation-max-thinking',
|
||
agentId: 'agent-1',
|
||
title: 'Max thinking controls',
|
||
archivedAt: null,
|
||
unread: false,
|
||
createdAt: '2026-08-28T00:00:00.000Z',
|
||
updatedAt: '2026-08-28T00:00:00.000Z',
|
||
model: { accountId: 'account-1', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' as const },
|
||
modelResolution: 'resolved' as const,
|
||
};
|
||
const snapshot: ConversationSnapshot = {
|
||
schemaVersion: 1,
|
||
conversation: {
|
||
id: conversation.id,
|
||
projectId: 'project-1',
|
||
agentId: conversation.agentId,
|
||
title: conversation.title,
|
||
model: {
|
||
model: conversation.model,
|
||
modelResolution: 'resolved',
|
||
availableThinkingLevels: ['off', 'low', 'high', 'max'],
|
||
},
|
||
},
|
||
nodes: [],
|
||
run: { status: 'idle' },
|
||
queue: { items: [] },
|
||
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
|
||
pendingInteractions: [],
|
||
worker: { status: 'ready', generation: 1 },
|
||
cursor: { workerGeneration: 1, seq: 0 },
|
||
};
|
||
|
||
render(
|
||
<CodingComposerRuntimeControls
|
||
conversation={conversation}
|
||
snapshot={snapshot}
|
||
onRefresh={vi.fn(async () => undefined)}
|
||
/>,
|
||
);
|
||
|
||
const settingsTrigger = screen.getByRole('button', {
|
||
name: '模型与思考设置:deepseek-v4-pro,最高',
|
||
});
|
||
expect(settingsTrigger).toHaveTextContent('最高');
|
||
fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false });
|
||
const thinkingRow = await screen.findByRole('menuitem', { name: '推理强度 最高' });
|
||
fireEvent.click(thinkingRow);
|
||
expect(screen.getByRole('menuitemradio', { name: '最高' })).toBeInTheDocument();
|
||
expect(screen.queryByRole('menuitemradio', { name: '极简' })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('menuitemradio', { name: '中等' })).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 { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||
const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls');
|
||
const 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' as const },
|
||
modelResolution: 'resolved' as const,
|
||
};
|
||
const snapshot = {
|
||
schemaVersion: 1 as const,
|
||
conversation: {
|
||
id: 'conversation-uncertain',
|
||
projectId: 'project-1',
|
||
agentId: 'agent-1',
|
||
title: 'Slow provider',
|
||
model: {
|
||
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const },
|
||
modelResolution: 'resolved' as const,
|
||
availableThinkingLevels: ['off' as const, 'medium' as const],
|
||
},
|
||
},
|
||
nodes: [],
|
||
run: {
|
||
status: 'running' as const,
|
||
runId: 'run-uncertain',
|
||
mode: 'prompt' as const,
|
||
error: {
|
||
code: 'CODING_REQUEST_UNCERTAIN' as const,
|
||
message: '请求确认延迟,可能仍在执行。',
|
||
recoverable: true,
|
||
},
|
||
},
|
||
queue: { items: [] },
|
||
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' as const },
|
||
pendingInteractions: [],
|
||
worker: { status: 'ready' as const, generation: 1 },
|
||
cursor: { workerGeneration: 1, seq: 2 },
|
||
};
|
||
render(
|
||
<>
|
||
<CodingConversationHeader
|
||
conversation={conversation}
|
||
snapshot={snapshot}
|
||
onRename={vi.fn(async () => undefined)}
|
||
onAbort={async () => interactionApi.abort('conversation-uncertain')}
|
||
onRecover={recover}
|
||
/>
|
||
<CodingComposerRuntimeControls
|
||
conversation={conversation}
|
||
snapshot={snapshot}
|
||
onRefresh={vi.fn(async () => undefined)}
|
||
/>
|
||
</>,
|
||
);
|
||
|
||
expect(screen.getByRole('button', { name: /模型与思考设置/ })).toBeDisabled();
|
||
expect(screen.queryByRole('button', { name: '整理上下文' })).not.toBeInTheDocument();
|
||
expect(within(screen.getByTestId('coding-conversation-header'))
|
||
.queryByRole('button', { name: '创建分支' })).not.toBeInTheDocument();
|
||
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());
|
||
});
|
||
|
||
});
|