fix(agents): surface unapplied draft changes and publish action

This commit is contained in:
2026-09-18 10:32:12 +08:00
parent c878ba940c
commit b28f6f0444
9 changed files with 316 additions and 41 deletions

View File

@@ -12,6 +12,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
BrowserWindow.getAllWindows()[0].setSize(1366, 768);
let draft: Record<string, unknown> | null = null;
let recent: unknown = null;
const versions: { version: number; draft_revision: number; created_at: string }[] = [];
let knowledgeCatalogLoads = 0;
let knowledgeCreated = false;
let knowledgeUploaded = false;
@@ -68,8 +69,8 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
if (body.operation === 'channelSelfCallers') return result({ items: [] });
if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] });
if (body.operation === 'threads') return result({ threads: [], next_offset: null });
if (body.operation === 'publish') { if (draft) draft.published_version = 1; return result({ version: 1, draft_revision: 2 }); }
if (body.operation === 'access') return result({ published_version: draft?.published_version, enabled: true, share_url: 'niancode://agents/' + draft?.slug, versions: [], grants: [], applications: [] });
if (body.operation === 'publish') { const version = versions.length + 1; if (draft) draft.published_version = version; versions.push({ version, draft_revision: input.expected_revision, created_at: new Date().toISOString() }); return result({ version, draft_revision: input.expected_revision }); }
if (body.operation === 'access') return result({ published_version: draft?.published_version, enabled: true, share_url: 'niancode://agents/' + draft?.slug, versions, grants: [], applications: [] });
if (body.operation === 'costs') return result({ items: [], next_offset: null, unit: '词元点数', summary: { count: 0, settled_points: '0.00', pending_points: '0.00' } });
if (body.operation === 'schedules') return result({ jobs });
if (body.operation === 'createSchedule') { const job = { ...input, id: 'job-1', agent_slug: draft?.slug, next_run_at: '2026-09-11T01:00:00Z', runs: [] }; jobs.push(job); return result(job); }
@@ -272,6 +273,21 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByRole('button', { name: '确认开始使用' }).click();
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: '智能体对话', exact: true }).getByRole('heading', { name: '创作建议' })).toHaveCount(0);
await page.getByRole('button', { name: '编辑', exact: true }).click();
await page.getByLabel('名称', { exact: true }).fill('我的新版写作搭档');
await expect(page.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled();
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByText('修改已保存,尚未应用', { exact: true })).toBeVisible();
const applyButton = page.getByRole('button', { name: '应用最新修改', exact: true });
await expect(applyButton).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-apply-visible.png') });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1024, 768));
await expect(applyButton).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-apply-compact.png') });
await applyButton.click();
await expect(page.getByRole('button', { name: '已应用', exact: true })).toBeDisabled();
await expect(page.getByText('修改已保存,尚未应用', { exact: true })).toHaveCount(0);
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1366, 768));
await page.getByRole('button', { name: '对话', exact: true }).click();
await page.getByRole('region', { name: '智能体对话', exact: true }).getByLabel('消息', { exact: true }).fill('每天早上八点半给我三个创作方向');
await page.getByRole('region', { name: '智能体对话', exact: true }).getByRole('button', { name: '发送', exact: true }).click();

View File

@@ -3,7 +3,14 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudApproval, CloudChat } from '@/pages/CloudAgents/CloudChat';
import { AgentRequirements } from '@/pages/CloudAgents/AgentRequirements';
import { CloudAccessPanel } from '@/pages/CloudAgents/CloudAccess';
import { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess';
import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication';
function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) {
const publication = useCloudPublication(props.slug, props.revision, props.onPublished);
return <AccessPanel slug={props.slug} revision={props.revision} publication={publication} />;
}
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), download: vi.fn(), recovery: vi.fn(), remember: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
@@ -36,7 +43,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 }; }
if (operation === 'publish') { if (++attempts === 1) throw new Error('连接中断'); 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);

View File

@@ -47,6 +47,125 @@ it('does not label disabled or archived assistants as ready to use', async () =>
expect(screen.queryByText('可以使用')).not.toBeInTheDocument();
});
function publishedAssistant(appliedRevision = 3) {
let current = { ...draft, draft_revision: 4, published_version: 8, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'model-a' } };
let applied = appliedRevision;
const base = api.call.getMockImplementation()!;
api.list.mockImplementation(async () => ({ agents: [current], next_cursor: null }));
api.recovery.mockResolvedValue({ recent: { slug: draft.slug, mode: 'preview', draft_revision: 4 }, pending: [] });
api.get.mockImplementation(async () => current);
api.save.mockImplementation(async (_slug, input) => (current = { ...current, ...input, draft_revision: current.draft_revision + 1 }));
api.call.mockImplementation(async (operation, input) => {
if (operation === 'access') return { published_version: current.published_version, enabled: true,
versions: [{ version: 1, draft_revision: 1, created_at: '' }, { version: current.published_version, draft_revision: applied, created_at: '' }], grants: [], applications: [] };
if (operation === 'publish') {
applied = input.expected_revision;
current = { ...current, published_version: current.published_version + 1 };
return { version: current.published_version, draft_revision: applied };
}
if (operation === 'catalog') return { models: [{ id: 'model-a', name: '模型甲' }], resources: {}, pricing: null };
if (operation === 'channelBindings') return { items: [], available_accounts: [] };
if (operation === 'costs') return { items: [], summary: {}, next_offset: null };
return base(operation, input);
});
}
it('shows saved but unapplied changes and applies directly from the editor header', async () => {
publishedAssistant();
open();
await screen.findByTestId('cloud-agent-editor');
await screen.findByText('修改已保存,尚未应用');
expect(screen.getByText(/保存只更新草稿/)).toBeVisible();
expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')).toEqual([
['publish', expect.objectContaining({ slug: draft.slug, expected_revision: 4 })],
]);
expect(screen.queryByText('修改已保存,尚未应用')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '编辑', exact: true })).toHaveAttribute('aria-current', 'page');
});
it('uses the published draft revision, requires saving edits, and keeps trial inputs when applying', async () => {
publishedAssistant(4);
open();
await screen.findByTestId('cloud-agent-editor');
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '保留试用问题' } });
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '新的要求' } });
expect(screen.getByText('修改尚未保存和应用')).toBeVisible();
expect(screen.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('修改已保存,尚未应用');
expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
expect(screen.getByLabelText('消息')).toHaveValue('保留试用问题');
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')[0][1]).toMatchObject({ expected_revision: 5 });
});
it('shares the original failed publication between the header and access page', async () => {
publishedAssistant();
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'publish' && ++attempts === 1) throw new Error('连接中断');
return base(operation, input);
});
open();
await screen.findByTestId('cloud-agent-editor');
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: '使用与分享' }));
fireEvent.click(await screen.findByRole('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);
expect(calls[0][1]).toEqual(calls[1][1]);
});
it('does not call an unread publication status up to date and offers a read retry', async () => {
publishedAssistant(4);
const base = api.call.getMockImplementation()!;
let fail = true;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'access' && fail) throw new Error('状态读取失败');
return base(operation, input);
});
open();
await screen.findByTestId('cloud-agent-editor');
await screen.findByText(/状态读取失败/);
expect(screen.getByText('应用状态待确认')).toBeVisible();
expect(screen.queryByRole('button', { name: '已应用', exact: true })).not.toBeInTheDocument();
fail = false;
fireEvent.click(screen.getByRole('button', { name: '刷新应用状态' }));
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false);
});
it('keeps a successful application confirmed when its following status refresh fails', async () => {
publishedAssistant();
const base = api.call.getMockImplementation()!;
let published = false;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'access' && published) throw new Error('状态刷新失败');
const result = await base(operation, input);
if (operation === 'publish') published = true;
return result;
});
open();
await screen.findByText('修改已保存,尚未应用');
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();
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);
});
it('protects a custom requirement before a name or purpose has been entered', async () => {
const { router } = open();
fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true }));

View File

@@ -1,11 +1,18 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudChat } from '@/pages/CloudAgents/CloudChat';
import { CloudAccessPanel } from '@/pages/CloudAgents/CloudAccess';
import { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess';
import { CloudSchedules } from '@/pages/CloudAgents/CloudSchedules';
import { CloudBudgetEditor, CloudCosts } from '@/pages/CloudAgents/CloudCosts';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication';
function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) {
const publication = useCloudPublication(props.slug, props.revision, props.onPublished);
return <AccessPanel slug={props.slug} revision={props.revision} publication={publication} />;
}
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), download: vi.fn(), remember: vi.fn(), recovery: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; }