Files
makelore/tests/unit/coding-feature-ui.test.tsx

723 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

import { 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());
});
});