fix: clarify application retry prompts

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 17:19:36 +08:00
1 parent 1a496dd4fb
commit 5a8b8d75e5
12 files changed
+150 -29

No files matched your search

+31 -3
View File
@@ -83,7 +83,7 @@ type FixtureCall = { path: string; method: string; input: Record<string, unknown
type RecoveryFixture = { agents: CloudAgentDraft[]; pending: CloudPendingOperation[]; retry: 'fail' | 'success'; calls: FixtureCall[] };
type FixtureWindow = Window & { __recoveryFixture: RecoveryFixture };
async function installFixture(page: Page, count = 2) {
async function installFixture(page: Page, count = 2, publication = false) {
page.on('pageerror', error => { throw error; });
await page.addInitScript(({ agent, records, retryError }) => {
const scope = window as unknown as FixtureWindow;
@@ -104,7 +104,12 @@ async function installFixture(page: Page, count = 2) {
const record = state.pending.find(item => item.id === input?.id);
if (!record) throw new Error('Recovery must keep the original pending id');
if (input?.discard) { state.pending = state.pending.filter(item => item.id !== record.id); return result({ discarded: true }); }
if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError } } };
if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError,
...(record.operation === 'publish' ? { code: 'cloud_service_unavailable', commandOutcome: 'unknown' } : {}) } } };
if (record.operation === 'publish') {
state.pending = state.pending.filter(item => item.id !== record.id);
return result({ operation: 'publish', result: { version: 1, draft_revision: record.input.expected_revision } });
}
const created = { ...agent, slug: 'ml-' + 'b'.repeat(32), name: String(record.input.name), published_version: null };
state.agents.push(created); state.pending = state.pending.filter(item => item.id !== record.id);
return result({ operation: 'createAgent', result: created });
@@ -124,7 +129,9 @@ async function installFixture(page: Page, count = 2) {
}
},
} } });
}, { agent, records: records.slice(0, count), retryError });
}, { agent, records: publication ? [{ id: 'e2e-publish-original', operation: 'publish',
input: { slug: agent.slug, operation_id: 'e2e-publish-original', expected_revision: 1 }, created_at: records[0].created_at }]
: records.slice(0, count), retryError });
}
async function recoveryCalls(page: Page) {
@@ -192,6 +199,27 @@ test('retry keeps the same pending identity and errors remain inside the modal',
expect(await recoveryCalls(page)).toHaveLength(2);
});
test('an application reminder gives a simple action and reuses its record after a network failure', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page, 1, true); await page.goto(baseUrl);
await page.getByRole('button', { name: /^待处理记录/ }).click();
const dialog = page.getByRole('dialog', { name: '待处理记录', exact: true });
await expect(dialog.getByRole('heading', { name: '应用设置', exact: true })).toBeVisible();
await expect(dialog.getByText('网络可能不稳定,请再点一次「应用」。', { exact: true })).toBeVisible();
expect(await recoveryCalls(page)).toEqual([]);
const apply = dialog.getByRole('button', { name: '应用', exact: true });
await apply.click();
await expect(dialog.getByRole('alert')).toHaveText('网络可能不稳定,请再点一次「应用」。');
await expect(apply).toBeEnabled(); await expectModalFits(dialog);
await page.screenshot({ path: test.info().outputPath('application-message-375.png'), animations: 'disabled' });
await page.evaluate(() => { (window as unknown as FixtureWindow).__recoveryFixture.retry = 'success'; });
await apply.click();
await expect(dialog.getByText('设置已应用。', { exact: true })).toBeVisible();
await expect(dialog.getByText('查看结果', { exact: true })).toHaveCount(0);
expect((await recoveryCalls(page)).map(call => call.input)).toEqual([
{ id: 'e2e-publish-original', discard: false }, { id: 'e2e-publish-original', discard: false },
]);
});
test('discard is explicit and reminders do not follow into configuration, chat or creation', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl);
const trigger = page.getByRole('button', { name: /^创建记录/ });
+8 -7
View File
@@ -1,6 +1,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { AppError } from '@/lib/error-model';
import CloudAgents from '@/pages/CloudAgents';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
@@ -149,17 +150,17 @@ it('retries an uncertain activation with the original operation and keeps pendin
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'publish' && ++attempts === 1) throw new Error('连接中断');
if (operation === 'publish' && ++attempts === 1) throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' });
return base(operation, input);
});
await openConfiguration();
fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true }));
await screen.findByText(/连接中断/);
await screen.findByText(/网络可能不稳定,请再点一次「应用」。/);
expect(screen.queryByRole('region', { name: '智能体对话' })).not.toBeInTheDocument();
const unload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '重试开始使用', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
expect(publications()).toHaveLength(2);
expect(publications()[1][1]).toEqual(publications()[0][1]);
@@ -180,7 +181,7 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
versions: activated ? [{ version: 1, draft_revision: 3, created_at: '' }] : [],
};
if (operation === 'publish') {
if (++attempts === 1) throw new Error('连接中断');
if (++attempts === 1) throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' });
activated = true;
current = { ...current, published_version: 1 };
return { version: 1, draft_revision: input.expected_revision };
@@ -189,11 +190,11 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
});
await openConfiguration();
fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true }));
await screen.findByText(/连接中断/);
await screen.findByText(/网络可能不稳定,请再点一次「应用」。/);
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '这次新保存的要求' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: '已保存', exact: true })).toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '重试开始使用', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
expect(screen.queryByRole('button', { name: '应用最新修改', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
@@ -225,7 +226,7 @@ it('keeps successful activation and conversation when the following status read
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(2));
expect(screen.queryByRole('button', { name: '已应用', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '重试开始使用', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '应用', exact: true })).not.toBeInTheDocument();
expect(screen.queryByText(/状态刷新失败/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true }));
+22 -3
View File
@@ -5,6 +5,7 @@ import { CloudApproval, CloudChat } from '@/pages/CloudAgents/CloudChat';
import { AgentRequirements } from '@/pages/CloudAgents/AgentRequirements';
import { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess';
import { AppError } from '@/lib/error-model';
import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication';
function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) {
@@ -43,7 +44,7 @@ it('starts private use through the existing publication action and retains its i
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'access') return { published_version: published ? 1 : null, enabled: true, versions: [], grants: [], applications: [] };
if (operation === 'publish') { if (++attempts === 1) throw new Error('连接中断'); published = true; return { version: 1, draft_revision: input.expected_revision }; }
if (operation === 'publish') { if (++attempts === 1) throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); published = true; return { version: 1, draft_revision: input.expected_revision }; }
if (operation === 'costs') return { items: [], next_offset: null, summary: {} };
if (operation === 'channelBindings') return { items: [] };
return base(operation, input);
@@ -53,9 +54,9 @@ it('starts private use through the existing publication action and retains its i
await waitFor(() => expect(screen.getByRole('button', { name: '确认开始使用' })).toBeEnabled());
expect(screen.queryByLabelText('应用名称')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认开始使用' }));
await screen.findByText(/连接中断/);
await screen.findByText(/网络可能不稳定,请再点一次「应用」。/);
view.rerender(<CloudAccessPanel slug={slug} revision={3} onPublished={onPublished} />);
fireEvent.click(screen.getByRole('button', { name: '重试本次发布' }));
fireEvent.click(screen.getByRole('button', { name: '应用' }));
await screen.findByText('助手已准备好');
const publications = api.call.mock.calls.filter(([operation]) => operation === 'publish');
expect(publications).toHaveLength(2);
@@ -65,6 +66,24 @@ it('starts private use through the existing publication action and retains its i
expect(api.call.mock.calls.some(([operation]) => operation === 'share' || operation === 'createApplication')).toBe(false);
});
it.each([
{ status: 401, code: 'session_expired', message: '请重新登录' },
{ status: 403, code: 'access_revoked', message: '无权使用这个智能体' },
{ status: 409, code: 'draft_revision_conflict', message: '云端草稿已更新' },
{ status: 422, code: 'invalid_input', message: '请选择模型' },
{ status: 429, code: 'cloud_service_unavailable', message: '请求过于频繁,请稍后再试' },
])('keeps the actual application rejection for HTTP $status', async ({ status, code, message }) => {
api.call.mockImplementation(async operation => {
if (operation === 'access') return { published_version: null, enabled: true, versions: [], grants: [], applications: [] };
throw new AppError('UNKNOWN', message, undefined, { status, backendCode: code, commandOutcome: 'unknown' });
});
render(<CloudAccessPanel slug={slug} revision={2} onPublished={vi.fn()} />);
await waitFor(() => expect(screen.getByRole('button', { name: '确认开始使用' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '确认开始使用' }));
expect(await screen.findByRole('alert')).toHaveTextContent(message);
expect(screen.getByRole('alert')).not.toHaveTextContent('网络可能不稳定');
});
it('preserves the entire prompt when displaying and editing former style instruction lines', () => {
const initial = '我的规则\n\n 保留缩进和解释。\n使用简单易懂的语言,遇到新词先解释。\n把任务拆成小步骤,一次引导我完成一步。\n先给我提示和思考问题,让我尝试后再补充答案。\n';
const edited = '先给我提示和思考问题,让我尝试后再补充答案。\n\n 我的新规则\n使用简单易懂的语言,遇到新词先解释。\n把任务拆成小步骤,一次引导我完成一步。\n\n';
+3 -3
View File
@@ -145,8 +145,8 @@ it('retries a failed application from the header using the original publication
await screen.findByText('修改已保存,尚未应用');
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await screen.findByText(/连接中断/);
expect(screen.getByRole('button', { name: '重试本次应用' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '重试本次应用' }));
expect(screen.getByRole('button', { name: '应用' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '应用' }));
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
const calls = api.call.mock.calls.filter(([operation]) => operation === 'publish');
expect(calls).toHaveLength(2);
@@ -187,7 +187,7 @@ it('keeps a successful application confirmed when its following status refresh f
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await screen.findByText(/状态刷新失败/);
expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled();
expect(screen.queryByRole('button', { name: '重试本次应用' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '应用' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '刷新应用状态' }));
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(3));
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')).toHaveLength(1);
+33
View File
@@ -47,6 +47,39 @@ it('uses a general records entry for mixed operations and keeps the original mes
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('explains a pending application in simple language and retries only its original record', async () => {
const publication: CloudPendingOperation = { ...first, id: 'publish:original', operation: 'publish',
input: { slug: 'ml-' + 'a'.repeat(32), operation_id: 'original-publication', expected_revision: 3 } };
api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '智能体服务暂时不可用,请重试', undefined,
{ status: 502, backendCode: 'cloud_service_unavailable', commandOutcome: 'unknown' }))
.mockResolvedValueOnce({ result: { version: 2, draft_revision: 3 } });
render(<CloudRecovery pending={[publication]} onResolved={resolved} />);
await openRecords();
const row = screen.getByRole('article', { name: '应用设置' });
expect(within(row).getByText('网络可能不稳定,请再点一次「应用」。')).toBeVisible();
expect(screen.queryByText('发布版本')).not.toBeInTheDocument();
expect(api.resolvePending).not.toHaveBeenCalled();
fireEvent.click(within(row).getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('网络可能不稳定,请再点一次「应用」。');
expect(resolved).not.toHaveBeenCalled();
fireEvent.click(within(row).getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByText('设置已应用。')).toBeVisible();
expect(api.resolvePending.mock.calls).toEqual([[publication.id, false], [publication.id, false]]);
expect(screen.queryByText('查看结果')).not.toBeInTheDocument();
});
it('keeps a publication conflict explicit instead of calling it a network failure', async () => {
const publication: CloudPendingOperation = { ...first, id: 'publish:conflict', operation: 'publish' };
api.resolvePending.mockRejectedValue(new AppError('UNKNOWN', '云端草稿已更新,请重新读取后再保存', undefined,
{ status: 409, backendCode: 'draft_revision_conflict', commandOutcome: 'unknown' }));
render(<CloudRecovery pending={[publication]} onResolved={resolved} />);
await openRecords(); fireEvent.click(screen.getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('云端草稿已更新,请重新读取后再保存');
expect(screen.getByRole('alert')).not.toHaveTextContent('网络可能不稳定');
expect(screen.queryByText('网络可能不稳定,请再点一次「应用」。')).not.toBeInTheDocument();
expect(api.resolvePending).toHaveBeenCalledWith(publication.id, false);
});
it('keeps failed records and retries the same record ID after closing and reopening the dialog', async () => {
api.resolvePending.mockRejectedValueOnce(new Error('原操作仍未确认')).mockResolvedValueOnce({ result: { slug: 'original-agent' } });
render(<CloudRecovery pending={[first]} onResolved={resolved} />);