feat(agents): 完善恢复费用与云资源交互

This commit is contained in:
2026-09-10 22:29:24 +08:00
parent f8f39f39de
commit 303f26294e
24 changed files with 1170 additions and 127 deletions

View File

@@ -10,6 +10,8 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
// This UI test owns the Host API boundary; real cloud HTTP/PG is tested separately.
await app.evaluate(({ ipcMain }) => {
let draft: Record<string, unknown> | null = null;
let recent: unknown = null;
let limits: { request_limit_points: string | null; daily_limit_points: string | null } = { request_limit_points: null, daily_limit_points: null };
const jobs: Record<string, unknown>[] = [];
const configuration = { model: '', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
@@ -29,18 +31,27 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
});
if (path.startsWith('/api/cloud-agents/')) {
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
if (path.endsWith('/recovery')) return result({ recent, pending: [] });
if (path.endsWith('/recovery/recent')) { recent = body; return result({ saved: true }); }
if (path.endsWith('/actions')) {
const input = body.input ?? {};
if (body.operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null });
if (body.operation === 'knowledge') return result({ databases: [], models: [] });
if (body.operation === 'resources') return result({ mcps: [], skills: [], subagents: [] });
if (body.operation === 'budget' || body.operation === 'saveBudget') {
if (body.operation === 'saveBudget') limits = input;
return result({ agent_slug: draft?.slug, ...limits, daily_committed_points: '0.00', timezone: 'Asia/Shanghai', unit: '词元点数', resets_at: '2026-09-11T00:00:00+08:00' });
}
if (body.operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史对话与活动', payer: 'creator' });
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 === 'costs') return result({ items: [], unit: '词元点数' });
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); }
if (body.operation === 'submit' || body.operation === 'preview') return result({ request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', version: '1', status: 'dispatched' });
if (body.operation === 'history') return result({ thread_id: 'thread-1', next_offset: null,
queued_requests: [], schedule_proposals: [{ id: 'proposal', name: '每天整理选题', prompt: '每天给我三个适合个人创作的选题', cron_expression: '30 8 * * *', timezone: 'Asia/Shanghai' }],
messages: [{ id: 1, role: 'assistant', content: '## 创作建议\n先从一个真实的观察开始。' }],
run: { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', status: 'completed', version: '1' } });
if (body.operation === 'viewed') return result({ viewed: true });
@@ -80,21 +91,31 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-cloud_agents').click();
await page.getByRole('button', { name: /我的写作搭档/ }).click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await expect(page.getByLabel('角色与指令')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await page.getByRole('button', { name: '发布与访问', exact: true }).click();
await page.getByLabel('每次任务上限(词元点数)').fill('2.50');
await page.getByLabel('每日上限(词元点数)').fill('10.00');
await page.getByRole('button', { name: '保存费用上限' }).click();
await expect(page.getByRole('button', { name: '保存费用上限' })).toBeDisabled();
await page.getByRole('button', { name: '发布当前草稿' }).click();
await expect(page.getByText('当前发布版本 1')).toBeVisible();
await page.getByRole('button', { name: '对话', exact: true }).click();
await page.getByLabel('消息', { exact: true }).fill('给我个创作方向');
await page.getByLabel('消息', { exact: true }).fill('每天早上八点半给我个创作方向');
await page.getByRole('button', { name: '发送', exact: true }).click();
await expect(page.getByRole('heading', { name: '创作建议' })).toBeVisible();
await page.getByRole('button', { name: '自动任务', exact: true }).click();
await page.getByRole('button', { name: '添加任务' }).click();
await page.getByLabel('任务名称').fill('每天整理选题');
await page.getByLabel('目标与要求').fill('每天给我三个适合个人创作的选题');
await page.getByRole('button', { name: '查看并配置自动任务' }).click();
await expect(page.getByLabel('任务名称')).toHaveValue('每天整理选题');
await expect(page.getByLabel('执行时间')).toHaveValue('08:30');
await expect(page.getByLabel('保存后启用')).not.toBeChecked();
await page.getByLabel('保存后启用').check();
await expect(page.getByText('启用前确认')).toBeVisible();
await page.getByRole('button', { name: '保存并启用' }).click();
await expect(page.getByRole('heading', { name: '每天整理选题' })).toBeVisible();
await expect(page.getByText(/每天 08:30/)).toBeVisible();
await page.getByRole('button', { name: '添加任务', exact: true }).click();
await expect(page.getByLabel('保存后启用')).not.toBeChecked();
await page.getByRole('button', { name: '取消', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-schedules.png') });
} finally {
await closeElectronApp(app);

View File

@@ -3,21 +3,23 @@ import { mkdtemp, readFile, writeFile, rm, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CloudAgentsModule } from '@electron/services/cloud-agents';
import { CloudAgentJournal } from '@electron/services/cloud-agent-journal';
import type { CloudRecoveryState } from '../../shared/cloud-agents';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session';
const desktop = vi.hoisted(() => ({ pick: vi.fn(), save: vi.fn(), show: vi.fn(), click: vi.fn() }));
vi.mock('electron', () => ({
vi.mock('electron', () => { const electron = {
app: { getPath: () => '/tmp/cloud-agents-test', getVersion: () => 'test', isPackaged: false },
dialog: { showOpenDialog: desktop.pick, showSaveDialog: desktop.save },
Notification: class { static isSupported() { return true; } on = desktop.click; show = desktop.show; },
BrowserWindow: { getAllWindows: () => [] },
}));
}; return { ...electron, default: electron }; });
const draft = {
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达',
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
configuration: EMPTY_CLOUD_CONFIGURATION, published_version: null, enabled: true,
configuration: EMPTY_CLOUD_CONFIGURATION, published_version: null, enabled: true, archived: false,
};
const input = { operation_id: '12345678-1234-1234-1234-123456789012', name: draft.name, purpose: draft.purpose };
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
@@ -29,13 +31,63 @@ function login(key = 'a'.repeat(64)) {
storeWorksSquareSession({ accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key });
}
const instances: CloudAgentsModule[] = [];
const journalRecords = new Map<string, CloudRecoveryState>();
const journal = () => new CloudAgentJournal(async () => ({
get: (key, fallback) => journalRecords.get(key) ?? fallback,
set: (key, value) => { journalRecords.set(key, value); },
}));
function moduleFor(fetchImpl: typeof fetch) {
const module = new CloudAgentsModule(fetchImpl); instances.push(module); return module;
const module = new CloudAgentsModule(fetchImpl, journal()); instances.push(module); return module;
}
beforeEach(() => { vi.clearAllMocks(); clearWorksSquareSession(); login(); });
beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); });
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('Main cloud Agents boundary', () => {
it('persists recovery in the real electron-store file and reloads it after replacing Main', async () => {
const directory = await mkdtemp(join(tmpdir(), 'makelore-cloud-journal-'));
try {
const { default: Store } = await import('electron-store');
const diskJournal = () => new CloudAgentJournal(async () => new Store<Record<string, CloudRecoveryState>>({
cwd: directory, name: 'recovery', projectVersion: '2.0.0',
}));
const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'));
const first = new CloudAgentsModule(transport, diskJournal()); instances.push(first);
await first.rememberRecent({ slug: draft.slug, mode: 'published', thread_id: 'durable-thread' });
await expect(first.execute({ operation: 'submit', input: { slug: draft.slug, request_id: 'durable-request', thread_id: 'durable-thread', query: '待确认任务' } })).rejects.toThrow();
first.dispose();
const raw = await readFile(join(directory, 'recovery.json'), 'utf8');
expect(raw).toContain('durable-request');
expect(raw).not.toContain('ws-secret'); expect(raw).not.toContain('yuxi-secret');
const restored = new CloudAgentsModule(vi.fn(), diskJournal()); instances.push(restored);
const state = await restored.recovery();
expect(state.recent?.thread_id).toBe('durable-thread');
expect(state.pending[0].input.query).toBe('待确认任务');
login('b'.repeat(64));
expect(await restored.recovery()).toEqual({ recent: null, pending: [] });
} finally { await rm(directory, { recursive: true, force: true }); }
});
it('restores an uncertain operation and recent location in a new Main instance without crossing accounts', async () => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost'))
.mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
request_id: 'server-request', thread_id: 'server-thread', run_id: null, status: 'queued', version: '1',
}));
const first = moduleFor(transport);
const input = { slug: draft.slug, request_id: 'stable-request', thread_id: 'stable-thread', query: '原始任务' };
await first.rememberRecent({ slug: draft.slug, mode: 'published', thread_id: 'stable-thread' });
await expect(first.execute({ operation: 'submit', input })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
first.dispose();
const restored = moduleFor(transport);
login('b'.repeat(64));
expect(await restored.recovery()).toEqual({ recent: null, pending: [] });
login();
const state = await restored.recovery();
expect(state.recent?.slug).toBe(draft.slug);
expect(state.pending).toHaveLength(1);
expect(transport).toHaveBeenCalledTimes(2);
expect(await restored.resolvePending({ id: state.pending[0].id })).toMatchObject({ result: { request_id: 'server-request' } });
expect(JSON.parse(transport.mock.calls[3][1].body)).toEqual(JSON.parse(transport.mock.calls[1][1].body));
expect((await restored.recovery()).pending).toEqual([]);
});
it('reads upload bytes and writes downloaded bytes only through Main dialogs', async () => {
const directory = await mkdtemp(join(tmpdir(), 'makelore-cloud-files-'));
try {

View File

@@ -0,0 +1,83 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudResources } from '@/pages/CloudAgents/CloudResources';
import { CloudLifecycle } from '@/pages/CloudAgents/CloudLifecycle';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), get: vi.fn(), uploadSkill: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
const slug = 'ml-' + 'a'.repeat(32);
const draft = { slug, name: '当前名称', purpose: '创作', system_prompt: '当前指令', configuration: EMPTY_CLOUD_CONFIGURATION,
draft_revision: 4, published_version: 2, enabled: true, archived: false, updated_at: '2026-09-10T00:00:00Z' };
beforeEach(() => vi.resetAllMocks());
afterEach(cleanup);
it('keeps existing MCP credentials when editing, and clears them only when explicitly selected', async () => {
const mcp = { slug: 'personal-mcp', name: '私人资料', description: '', url: 'https://example.test/mcp', transport: 'streamable_http', enabled: true, has_credentials: true };
api.call.mockImplementation(async operation => operation === 'resources' ? { mcps: [mcp], skills: [], subagents: [] } : mcp);
render(<CloudResources onChanged={() => undefined} />);
fireEvent.click(screen.getByText('管理我的云端能力'));
fireEvent.click(await screen.findByRole('button', { name: '编辑连接' }));
expect(screen.getByLabelText('Bearer 凭据')).toHaveValue('');
fireEvent.change(screen.getByLabelText('连接名称'), { target: { value: '新名称' } });
fireEvent.click(screen.getByRole('button', { name: '保存连接' }));
await waitFor(() => expect(screen.queryByLabelText('连接名称')).not.toBeInTheDocument());
expect(api.call.mock.calls.find(call => call[0] === 'updateMcp')?.[1]).toMatchObject({ key: 'personal-mcp', name: '新名称', headers: null });
fireEvent.click(screen.getByRole('button', { name: '编辑连接' }));
fireEvent.click(screen.getByLabelText('清除已有凭据'));
fireEvent.click(screen.getByRole('button', { name: '保存连接' }));
await waitFor(() => expect(api.call.mock.calls.filter(call => call[0] === 'updateMcp')).toHaveLength(2));
expect(api.call.mock.calls.filter(call => call[0] === 'updateMcp')[1][1].headers).toEqual({});
});
it('keeps one child creation intent after a lost response and waits for Skill installation confirmation', async () => {
let attempt = 0;
api.call.mockImplementation(async operation => {
if (operation === 'resources') return { mcps: [], skills: [], subagents: [] };
if (operation === 'createChild') { if (++attempt === 1) throw new Error('超时'); return { slug: 'child' }; }
if (operation === 'confirmSkill') return { items: [{ slug: 'writer', success: true }] };
throw new Error(operation);
});
api.uploadSkill.mockResolvedValue({ draft_id: 'draft', items: [{ slug: 'writer', name: '写作', description: '辅助写作' }] });
render(<CloudResources onChanged={() => undefined} />);
fireEvent.click(screen.getByText('管理我的云端能力'));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('resources', {}));
await waitFor(() => expect(screen.getByRole('button', { name: '创建子智能体' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '创建子智能体' }));
fireEvent.change(screen.getByLabelText('子智能体名称'), { target: { value: '核对' } });
fireEvent.change(screen.getByLabelText('子智能体用途'), { target: { value: '核对事实' } });
fireEvent.change(screen.getByLabelText('子智能体指令'), { target: { value: '检查材料' } });
fireEvent.click(screen.getByRole('button', { name: '保存子智能体' }));
fireEvent.click(await screen.findByRole('button', { name: '重试保存子智能体' }));
await waitFor(() => expect(screen.queryByLabelText('子智能体名称')).not.toBeInTheDocument());
const calls = api.call.mock.calls.filter(call => call[0] === 'createChild');
expect(calls).toHaveLength(2); expect(calls[0][1]).toEqual(calls[1][1]);
fireEvent.click(screen.getByRole('button', { name: '上传 Skill' }));
const confirm = await screen.findByRole('button', { name: '确认安装到个人能力' });
expect(api.call.mock.calls.some(call => call[0] === 'confirmSkill')).toBe(false);
fireEvent.click(confirm);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmSkill', { draft_id: 'draft' }));
});
it('compares an old publication, restores only a draft and requires confirmation to archive', async () => {
api.get.mockResolvedValue(draft);
api.call.mockImplementation(async operation => {
if (operation === 'version') return { ...draft, version: 1, name: '旧名称', system_prompt: '旧指令' };
if (operation === 'restoreVersion') return { ...draft, draft_revision: 5, name: '旧名称' };
if (operation === 'archiveAgent') return { ...draft, archived: true, enabled: false };
throw new Error(operation);
});
const changed = vi.fn();
render(<CloudLifecycle slug={slug} revision={4} versions={[1]} onChanged={changed} />);
fireEvent.click(screen.getByRole('button', { name: '比较版本 1' }));
expect(await screen.findByText('旧指令')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '恢复为新草稿' }));
await waitFor(() => expect(changed).toHaveBeenCalledWith(expect.objectContaining({ draft_revision: 5, published_version: 2 })));
expect(api.call).toHaveBeenCalledWith('restoreVersion', { slug, version: '1', expected_revision: 4 });
expect(api.call.mock.calls.some(call => call[0] === 'publish')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '管理归档' }));
const confirm = await screen.findByRole('button', { name: '确认归档' });
expect(api.call.mock.calls.some(call => call[0] === 'archiveAgent')).toBe(false);
fireEvent.click(confirm);
await waitFor(() => expect(changed).toHaveBeenCalledWith(expect.objectContaining({ archived: true, enabled: false })));
});

View File

@@ -4,7 +4,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import CloudAgents from '@/pages/CloudAgents';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn() }));
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() }));
const auth = vi.hoisted(() => ({ user: { userId: 'creator' } }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof auth) => unknown) => selector(auth) }));
@@ -23,12 +23,38 @@ function open() {
}
beforeEach(() => {
vi.resetAllMocks(); auth.user.userId = 'creator';
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.remember.mockResolvedValue({ saved: true });
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
api.call.mockImplementation(async (operation: string) => operation === 'knowledge'
? { databases: [], models: [] } : { models: [], resources: {}, pricing: null });
});
afterEach(cleanup);
it('restores the exact previous thread and presents an uncertain operation without automatically replaying it', async () => {
const published = { ...draft, published_version: 1 };
const pending = { id: 'submit:pending', operation: 'submit', input: { slug: draft.slug, request_id: 'same-request', thread_id: 'older-thread', query: '上次未确认输入' }, created_at: '2026-09-10T01:00:00Z' };
api.recovery.mockResolvedValue({ recent: { slug: draft.slug, thread_id: 'older-thread', mode: 'published' }, pending: [pending] });
api.get.mockResolvedValue(published);
api.call.mockImplementation(async (operation: string, input) => {
if (operation === 'threads') return { threads: ['newer-thread', 'older-thread'].map(thread_id => ({ thread_id, client_thread_id: thread_id, mode: 'published', title: thread_id })), next_offset: null };
if (operation === 'history') return { thread_id: input.thread_id, messages: [{ id: 1, role: 'assistant', content: '恢复了旧对话' }], run: null, next_offset: null };
if (operation === 'attachments') return { attachments: [] };
if (operation === 'files') return { files: [] };
throw new Error(operation);
});
open();
await screen.findByText('恢复了旧对话');
expect(screen.getByLabelText('历史对话')).toHaveValue('older-thread');
expect(api.resolvePending).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } });
api.recovery.mockResolvedValue({ recent: null, pending: [] });
fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' }));
await screen.findByText('发送消息的结果已确认。');
expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false);
});
it('keeps creation input and its uncertain operation when an existing card is clicked', async () => {
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
open();

View File

@@ -3,18 +3,26 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudChat } from '@/pages/CloudAgents/CloudChat';
import { CloudAccessPanel } 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';
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), download: vi.fn() }));
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; }
const stream = new Stream();
const slug = 'ml-' + 'a'.repeat(32);
const budget = { agent_slug: slug, request_limit_points: null, daily_limit_points: null, daily_committed_points: '0.00', timezone: 'Asia/Shanghai', unit: '词元点数', resets_at: '2026-09-11T00:00:00+08:00' };
const scheduleContext = { version: 2, enabled: true, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'model-a' }, result_destination: '任务历史对话与活动', payer: 'creator' };
const run = { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', agent_slug: slug, status: 'running', version: '2', output: '' };
const history = { thread_id: 'thread-1', messages: [], run, next_offset: null };
beforeEach(() => {
vi.resetAllMocks();
api.remember.mockResolvedValue({ saved: true });
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.events.mockResolvedValue(stream);
api.call.mockImplementation(async (operation: string) => {
if (operation === 'budget') return budget;
if (operation === 'scheduleContext') return scheduleContext;
if (operation === 'threads') return { threads: [], next_offset: null };
if (operation === 'history') return history;
if (operation === 'attachments') return { attachments: [] };
@@ -25,6 +33,98 @@ beforeEach(() => {
});
afterEach(cleanup);
it('recovers a server queue and cancels it after remount without resubmitting messages', async () => {
const base = api.call.getMockImplementation()!;
let cancelled = false;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null };
if (operation === 'history') return { ...history, run: { ...run, status: 'completed' }, queued_requests: cancelled ? [] : [{ request_id: 'queued-request', thread_id: 'thread-1', run_id: null, status: 'queued', version: '1' }] };
if (operation === 'request') return { request_id: 'queued-request', thread_id: 'thread-1', run_id: null, status: 'queued', version: '1' };
if (operation === 'cancelRequest') { cancelled = true; return { status: 'cancelled' }; }
return base(operation, input);
});
render(<CloudChat slug={slug} />);
fireEvent.click(await screen.findByRole('button', { name: '取消排队' }));
await waitFor(() => expect(screen.queryByRole('button', { name: '取消排队' })).not.toBeInTheDocument());
expect(api.call).toHaveBeenCalledWith('cancelRequest', { request_id: 'queued-request' });
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
});
it('returns to the last selected thread on remount and requires restoring an archived conversation before sending', async () => {
const base = api.call.getMockImplementation()!;
const threads = ['newest', 'older'].map(id => ({ thread_id: id, client_thread_id: id, agent_slug: slug, mode: 'published' as const, title: id, status: 'idle', updated_at: '', run_id: null, unread: false }));
let recent = { slug, mode: 'published' as const, thread_id: 'older' };
api.recovery.mockImplementation(async () => ({ recent, pending: [] }));
api.remember.mockImplementation(async value => { recent = value; return { saved: true }; });
api.call.mockImplementation(async (operation, input) => {
if (operation === 'threads') return { threads, next_offset: null };
if (operation === 'history') return { thread_id: input.thread_id, messages: [], run: null, next_offset: null };
if (operation === 'archiveThread') return { thread_id: input.thread_id, archived: false };
return base(operation, input);
});
const first = render(<CloudChat slug={slug} />);
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('older'));
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'newest' } });
await waitFor(() => expect(recent.thread_id).toBe('newest'));
first.unmount();
const second = render(<CloudChat slug={slug} />);
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('newest'));
second.unmount();
recent = { ...recent, thread_id: 'older' };
const beforeArchive = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'threads'
? { threads: input.archived ? [{ ...threads[1], archived: true }] : [threads[0]], next_offset: null }
: beforeArchive(operation, input));
render(<CloudChat slug={slug} />);
const restore = await screen.findByRole('button', { name: '恢复对话' });
expect(screen.getByLabelText('消息')).toBeDisabled();
fireEvent.click(restore);
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
expect(api.call).toHaveBeenCalledWith('archiveThread', { thread_id: 'older', archived: false });
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
});
it('keeps proposed schedules disabled until the creator enables and reviews them', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'schedules') return { jobs: [] };
if (operation === 'createSchedule') return { id: 'job', ...input };
return base(operation, input);
});
render(<CloudSchedules slug={slug} onOpen={() => undefined} proposal={{ id: 'proposal-1', name: '周末选题', prompt: '整理五个方向', cron_expression: '30 10 * * 6', timezone: 'Asia/Shanghai' }} />);
expect(screen.getByLabelText('任务名称')).toHaveValue('周末选题');
expect(screen.getByLabelText('执行时间')).toHaveValue('10:30');
expect(screen.getByLabelText('星期')).toHaveValue('6');
expect(screen.getByLabelText('保存后启用')).not.toBeChecked();
expect(api.call.mock.calls.some(call => call[0] === 'createSchedule')).toBe(false);
await screen.findByText('启用前确认');
fireEvent.change(screen.getByLabelText('执行时间'), { target: { value: '08:45' } });
fireEvent.click(screen.getByLabelText('保存后启用'));
fireEvent.click(screen.getByRole('button', { name: '保存并启用' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('createSchedule', expect.objectContaining({ cron_expression: '45 8 * * 6', enabled: true })));
});
it('saves independent Agent ceilings and pages cost results under the active source filter', async () => {
api.call.mockImplementation(async (operation, input) => {
if (operation === 'budget') return budget;
if (operation === 'saveBudget') return { ...budget, ...input };
if (operation === 'costs') return { items: [], next_offset: input.offset ? null : 50, summary: { count: 75, settled_points: '12.30', pending_points: '4.50' }, unit: '词元点数' };
throw new Error(operation);
});
render(<><CloudBudgetEditor slug={slug} /><CloudCosts slug={slug} applications={[]} /></>);
const request = await screen.findByLabelText('每次任务上限(词元点数)');
await waitFor(() => expect(request).toBeEnabled());
fireEvent.change(request, { target: { value: '20.50' } });
fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '100' } });
fireEvent.click(screen.getByRole('button', { name: '保存费用上限' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', { slug, request_limit_points: '20.50', daily_limit_points: '100' }));
fireEvent.change(screen.getByLabelText('费用来源'), { target: { value: 'api' } });
await waitFor(() => expect(screen.getByRole('button', { name: '加载更多费用' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '加载更多费用' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('costs', expect.objectContaining({ slug, source: 'api', offset: 50 })));
expect(screen.getByText(/共 75 次模型调用/)).toHaveTextContent('12.30');
});
it('retries the same request after a lost response and renders batched native SSE', async () => {
let attempts = 0;
const base = api.call.getMockImplementation()!;
@@ -78,6 +178,7 @@ it('keeps publication and access controls usable when cost history is unavailabl
api.call.mockImplementation(async (operation) => {
if (operation === 'access') return { published_version: 1, enabled: true, share_url: 'niancode://agents/' + slug, versions: [], grants: [], applications: [] };
if (operation === 'costs') throw new Error('费用暂不可用');
if (operation === 'budget') return budget;
throw new Error(operation);
});
render(<CloudAccessPanel slug={slug} revision={2} onPublished={() => undefined} />);
@@ -86,10 +187,28 @@ it('keeps publication and access controls usable when cost history is unavailabl
expect(await screen.findByRole('alert')).toHaveTextContent('费用暂不可用');
});
it('saves a manually created schedule disabled unless the creator selects enable', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'schedules') return { jobs: [] };
if (operation === 'createSchedule') return { id: 'job', ...input };
return base(operation, input);
});
render(<CloudSchedules slug={slug} onOpen={() => undefined} />);
fireEvent.click(await screen.findByRole('button', { name: '添加任务' }));
expect(screen.getByLabelText('保存后启用')).not.toBeChecked();
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '早间选题' } });
fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '给出三个选题' } });
fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('createSchedule', expect.objectContaining({ enabled: false })));
});
it('an uncertain schedule save retries the same operation and explicit enabled state', async () => {
let attempt = 0;
api.call.mockImplementation(async (operation) => {
if (operation === 'schedules') return { jobs: [] };
if (operation === 'budget') return budget;
if (operation === 'scheduleContext') return scheduleContext;
if (operation === 'createSchedule') { if (++attempt === 1) throw new Error('超时'); return { id: 'job' }; }
throw new Error(operation);
});
@@ -97,6 +216,9 @@ it('an uncertain schedule save retries the same operation and explicit enabled s
fireEvent.click(await screen.findByRole('button', { name: '添加任务' }));
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '早间选题' } });
fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '给出三个选题' } });
expect(screen.getByLabelText('保存后启用')).not.toBeChecked();
fireEvent.click(screen.getByLabelText('保存后启用'));
await waitFor(() => expect(screen.getByRole('button', { name: '保存并启用' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '保存并启用' }));
fireEvent.click(await screen.findByRole('button', { name: '重试保存' }));
await screen.findByRole('button', { name: '添加任务' });