feat(agents): 完成云智能体工作台与交互流程
This commit is contained in:
@@ -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;
|
||||
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 } });
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
@@ -27,11 +29,31 @@ 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('/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 === '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 === '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,
|
||||
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 });
|
||||
if (body.operation === 'attachments') return result({ attachments: [] });
|
||||
if (body.operation === 'files') return result({ files: [] });
|
||||
throw new Error('Unexpected cloud fixture operation ' + body.operation);
|
||||
}
|
||||
if (method === 'POST') draft = {
|
||||
slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose,
|
||||
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
|
||||
configuration, published_version: null, enabled: true,
|
||||
};
|
||||
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, draft_revision: 2 };
|
||||
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, configuration: body.configuration, draft_revision: 2 };
|
||||
if (method === 'GET' && (path.endsWith('/agents') || path.endsWith('/bootstrap'))) return result({ agents: draft ? [draft] : [], next_cursor: null });
|
||||
return result(draft);
|
||||
}
|
||||
@@ -48,6 +70,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
||||
await page.getByRole('button', { name: '创建草稿', exact: true }).click();
|
||||
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
|
||||
await page.getByLabel('角色与指令').fill('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
|
||||
await page.getByLabel('模型', { exact: true }).selectOption('test-model');
|
||||
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
||||
await expect(page.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '继续编辑', exact: true }).click();
|
||||
@@ -59,6 +82,20 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
await page.getByRole('button', { name: /我的写作搭档/ }).click();
|
||||
await expect(page.getByLabel('角色与指令')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
|
||||
await page.getByRole('button', { name: '发布与访问', exact: true }).click();
|
||||
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.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.getByRole('heading', { name: '每天整理选题' })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-schedules.png') });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,20 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findNianCodeDeepLinkUrl,
|
||||
parseNianCodeDeepLinkUrl,
|
||||
queueCloudAgentLink,
|
||||
takeCloudAgentRoute,
|
||||
} from '@electron/main/app-deep-link';
|
||||
|
||||
describe('NianCode app deep links', () => {
|
||||
it('opens only an agent locator and preserves it until Renderer is ready', () => {
|
||||
const slug = 'ml-' + 'a'.repeat(32);
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://agents/' + slug)).toMatchObject({ type: 'cloud-agent', slug });
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://agents/' + slug + '?token=secret')).toBeNull();
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://agents/../../settings')).toBeNull();
|
||||
queueCloudAgentLink(slug);
|
||||
expect(takeCloudAgentRoute()).toBe('/cloud-agents/shared/' + slug);
|
||||
expect(takeCloudAgentRoute()).toBeNull();
|
||||
});
|
||||
it('parses desktop auth callback request ids from niancode links', () => {
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?request_id=desktop-request-id')).toEqual({
|
||||
type: 'desktop-auth-callback',
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 { 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', () => ({
|
||||
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: () => [] },
|
||||
}));
|
||||
|
||||
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,
|
||||
};
|
||||
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 });
|
||||
const session = () => json({
|
||||
access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agent-drafts',
|
||||
access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agents',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test',
|
||||
});
|
||||
function login(key = 'a'.repeat(64)) {
|
||||
@@ -19,10 +32,119 @@ const instances: CloudAgentsModule[] = [];
|
||||
function moduleFor(fetchImpl: typeof fetch) {
|
||||
const module = new CloudAgentsModule(fetchImpl); instances.push(module); return module;
|
||||
}
|
||||
beforeEach(() => { clearWorksSquareSession(); login(); });
|
||||
beforeEach(() => { vi.clearAllMocks(); clearWorksSquareSession(); login(); });
|
||||
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
|
||||
|
||||
describe('Main cloud Agents boundary', () => {
|
||||
it('reads upload bytes and writes downloaded bytes only through Main dialogs', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'makelore-cloud-files-'));
|
||||
try {
|
||||
const source = join(directory, 'material.txt'), destination = join(directory, 'result.txt');
|
||||
await writeFile(source, 'local material');
|
||||
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
|
||||
desktop.save.mockResolvedValue({ canceled: false, filePath: destination });
|
||||
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
|
||||
object_name: 'private-upload', file_name: 'material.txt', parse_supported: false, parse_methods: [],
|
||||
})).mockResolvedValueOnce(new Response('cloud result'));
|
||||
const module = moduleFor(transport);
|
||||
expect((await module.upload())?.file_name).toBe('material.txt');
|
||||
const payload = transport.mock.calls[1][1].body as FormData;
|
||||
expect(await (payload.get('file') as File).text()).toBe('local material');
|
||||
expect(await module.download({ thread_id: 'thread', path: '/mnt/user-data/projects/one/result.txt' })).toEqual({ saved: true });
|
||||
expect(await readFile(destination, 'utf8')).toBe('cloud result');
|
||||
expect((await readdir(directory)).sort()).toEqual(['material.txt', 'result.txt']);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it('rejects an account switch while a file picker is open without reading or uploading it', async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
desktop.pick.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
const transport = vi.fn();
|
||||
const upload = moduleFor(transport).upload();
|
||||
const failure = expect(upload).rejects.toMatchObject({ code: 'account_changed' });
|
||||
await vi.waitFor(() => expect(desktop.pick).toHaveBeenCalled());
|
||||
login('b'.repeat(64));
|
||||
finish({ canceled: false, filePaths: ['not-read.txt'] });
|
||||
await failure;
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies only a new unread terminal state and resets its baseline on account switch', async () => {
|
||||
vi.useFakeTimers();
|
||||
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ agents: [], next_cursor: null }))
|
||||
.mockResolvedValueOnce(json({ threads: [{ run_id: 'one', status: 'running', unread: true }] }))
|
||||
.mockResolvedValue(json({ threads: [{ run_id: 'one', status: 'completed', unread: true }] }));
|
||||
const module = moduleFor(transport);
|
||||
await module.list();
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(desktop.show).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(desktop.show).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(desktop.show).toHaveBeenCalledTimes(1);
|
||||
login('b'.repeat(64));
|
||||
const calls = transport.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(transport).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it('allowlists execution fields and preserves safe publication metadata', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
|
||||
request_id: 'request', thread_id: 'thread', run_id: 'run', status: 'pending', version: '4',
|
||||
access_token: 'never-render', input_payload: { creator_id: 'private' },
|
||||
}));
|
||||
const module = moduleFor(fetchImpl);
|
||||
const result = await module.execute({ operation: 'submit', input: {
|
||||
slug: draft.slug, request_id: 'request', thread_id: 'thread', query: 'hello',
|
||||
creator_id: 'forged', payer: 'caller', model: 'unauthorized', snapshot: {},
|
||||
} });
|
||||
expect(result).toEqual({ request_id: 'request', thread_id: 'thread', run_id: 'run', status: 'pending', version: '4' });
|
||||
expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual({ request_id: 'request', thread_id: 'thread', query: 'hello' });
|
||||
});
|
||||
|
||||
it('uses the WS credential directly for the creator ledger and never exchanges it into Renderer data', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(json({ unit: '词元点数', items: [], access_token: 'private' }));
|
||||
const result = await moduleFor(fetchImpl).execute({ operation: 'costs', input: { slug: draft.slug } });
|
||||
expect(result).toEqual({ unit: '词元点数', items: [] });
|
||||
expect(fetchImpl.mock.calls[0][0]).toContain('/api/cloud-agents/costs?agent_slug=');
|
||||
expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects arbitrary operation names and route traversal before any request', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const module = moduleFor(fetchImpl);
|
||||
await expect(module.execute({ operation: 'proxy', input: { url: 'https://evil.test' } })).rejects.toMatchObject({ status: 422 });
|
||||
await expect(module.execute({ operation: 'run', input: { run_id: '../../admin' } })).rejects.toMatchObject({ status: 422 });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes user validation failures as 422 without returning upstream text', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: [{ msg: 'raw credential' }] }, 422));
|
||||
await expect(moduleFor(fetchImpl).execute({ operation: 'createSchedule', input: { slug: draft.slug } }))
|
||||
.rejects.toMatchObject({ status: 422, code: 'invalid_input' });
|
||||
});
|
||||
|
||||
it('cancels the active cloud stream immediately on account switch', async () => {
|
||||
let streamController!: ReadableStreamDefaultController<Uint8Array>;
|
||||
let transportSignal: AbortSignal | undefined;
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce((_url, init) => {
|
||||
transportSignal = init.signal;
|
||||
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
init.signal.addEventListener('abort', () => controller.error(new Error('aborted')));
|
||||
},
|
||||
}), { headers: { 'Content-Type': 'text/event-stream' } }));
|
||||
});
|
||||
const iterator = moduleFor(fetchImpl).events('run-id', '0-0', new AbortController().signal);
|
||||
const pending = iterator.next();
|
||||
const failed = expect(pending).rejects.toBeDefined();
|
||||
await vi.waitFor(() => expect(streamController).toBeDefined());
|
||||
login('b'.repeat(64));
|
||||
expect(transportSignal?.aborted).toBe(true);
|
||||
await failed;
|
||||
});
|
||||
it('keeps credentials in Main, projects the DTO and sends only creator-independent inputs', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(json({ ...draft, access_token: 'do-not-project', owner: 'private' }))
|
||||
|
||||
@@ -2,14 +2,16 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
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() }));
|
||||
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: 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) }));
|
||||
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,
|
||||
};
|
||||
function open() {
|
||||
const router = createMemoryRouter([
|
||||
@@ -22,6 +24,8 @@ function open() {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); auth.user.userId = 'creator';
|
||||
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
|
||||
api.call.mockImplementation(async (operation: string) => operation === 'knowledge'
|
||||
? { databases: [], models: [] } : { models: [], resources: {}, pricing: null });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -120,3 +124,23 @@ it('warns before leaving an uncertain creation and keeps its retry intent', asyn
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledTimes(2));
|
||||
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
|
||||
});
|
||||
|
||||
|
||||
it('keeps unsent preview text when configuration changes and waits before switching saved revision', async () => {
|
||||
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
|
||||
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
|
||||
api.save.mockResolvedValue({ ...configured, name: '新的名称', draft_revision: 2 });
|
||||
open();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
|
||||
const prompt = await screen.findByPlaceholderText('输入消息或任务目标…');
|
||||
fireEvent.change(prompt, { target: { value: '还未发送的问题' } });
|
||||
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
|
||||
expect(prompt).toHaveValue('还未发送的问题');
|
||||
expect(screen.getByText(/预览仍使用已保存的修订 1/)).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled();
|
||||
fireEvent.change(prompt, { target: { value: '' } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
|
||||
await screen.findByRole('button', { name: '已保存' });
|
||||
expect(api.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
160
tests/unit/cloud-agents-workflows.test.tsx
Normal file
160
tests/unit/cloud-agents-workflows.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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 { CloudSchedules } from '@/pages/CloudAgents/CloudSchedules';
|
||||
|
||||
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), download: 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 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.events.mockResolvedValue(stream);
|
||||
api.call.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'threads') return { threads: [], next_offset: null };
|
||||
if (operation === 'history') return history;
|
||||
if (operation === 'attachments') return { attachments: [] };
|
||||
if (operation === 'files') return { files: [] };
|
||||
if (operation === 'run') return run;
|
||||
throw new Error('Unexpected operation: ' + operation);
|
||||
});
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it('retries the same request after a lost response and renders batched native SSE', async () => {
|
||||
let attempts = 0;
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation !== 'submit') return base(operation, input);
|
||||
if (++attempts === 1) throw new Error('响应丢失');
|
||||
return { request_id: 'request-1', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' };
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled());
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '生成文章' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
|
||||
await screen.findByRole('button', { name: '重试本次发送' });
|
||||
expect(screen.getByLabelText('消息')).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试本次发送' }));
|
||||
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1'));
|
||||
const submissions = api.call.mock.calls.filter(call => call[0] === 'submit');
|
||||
expect(submissions[0][1]).toEqual(submissions[1][1]);
|
||||
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
|
||||
data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [
|
||||
{ stream_event: { type: 'message_delta', content: '第一段' } },
|
||||
{ stream_event: { type: 'message_delta', content: '文字' } },
|
||||
] } }),
|
||||
})));
|
||||
expect(screen.getByText('第一段文字')).toBeVisible();
|
||||
expect(screen.getByText(/版本 2/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('restores a durable question and follows the new resume run', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
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: 'interrupted', interrupt: {
|
||||
questions: [{ question_id: 'direction', question: '选择方向', options: ['海洋', '森林'] }],
|
||||
} } };
|
||||
if (operation === 'resume') return { run_id: 'run-2', status: 'pending' };
|
||||
if (operation === 'run' && input.run_id === 'run-2') return { ...run, agent_run_id: 'run-2', status: 'pending' };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
fireEvent.click(await screen.findByRole('radio', { name: '海洋' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
||||
expect(screen.getByRole('radio', { name: '海洋' })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
|
||||
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-2'));
|
||||
expect(api.call.mock.calls.find(call => call[0] === 'resume')?.[1].decision).toEqual({ direction: '海洋' });
|
||||
});
|
||||
|
||||
it('keeps publication and access controls usable when cost history is unavailable', async () => {
|
||||
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('费用暂不可用');
|
||||
throw new Error(operation);
|
||||
});
|
||||
render(<CloudAccessPanel slug={slug} revision={2} onPublished={() => undefined} />);
|
||||
expect(await screen.findByText('当前发布版本 1')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '停用智能体' })).toBeEnabled();
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('费用暂不可用');
|
||||
});
|
||||
|
||||
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 === 'createSchedule') { if (++attempt === 1) throw new Error('超时'); return { id: 'job' }; }
|
||||
throw new Error(operation);
|
||||
});
|
||||
render(<CloudSchedules slug={slug} onOpen={() => undefined} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '添加任务' }));
|
||||
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 screen.findByRole('button', { name: '添加任务' });
|
||||
const calls = api.call.mock.calls.filter(call => call[0] === 'createSchedule');
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0][1]).toEqual(calls[1][1]);
|
||||
expect(calls[0][1].enabled).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('returns to a newly created conversation from history without remounting the page', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'submit') return { request_id: 'new-request', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' };
|
||||
if (operation === 'history') return { ...history, run: { ...run, status: 'completed' } };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled());
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新对话主题' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
|
||||
await waitFor(() => expect(screen.getByLabelText('历史对话')).toBeEnabled());
|
||||
expect(screen.getByRole('option', { name: '新对话主题 · 已完成' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
||||
expect(screen.getByLabelText('历史对话')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'thread-1' } });
|
||||
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('thread-1'));
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'history' && call[1].thread_id === 'thread-1')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates an attachment conversation only when the selected file is confirmed', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'createThread') return { thread_id: 'thread-1', client_thread_id: input.thread_id };
|
||||
if (operation === 'confirmAttachment') return { attachments: [{ file_id: 'attachment-1' }] };
|
||||
return base(operation, input);
|
||||
});
|
||||
api.upload.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt',
|
||||
parse_supported: false, parse_methods: [],
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
fireEvent.click(screen.getByText('附件与产物'));
|
||||
const add = screen.getByRole('button', { name: '添加附件' });
|
||||
await waitFor(() => expect(add).toBeEnabled());
|
||||
fireEvent.click(add);
|
||||
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(add).toBeEnabled());
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false);
|
||||
expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument();
|
||||
fireEvent.click(add);
|
||||
const confirm = await screen.findByRole('button', { name: '加入此对话' });
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false);
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmAttachment', {
|
||||
thread_id: 'thread-1', attachments: [{ object_name: 'temporary/notes.txt', file_type: 'txt' }],
|
||||
}));
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1);
|
||||
await waitFor(() => expect(screen.getByText('附件与产物 · 本条消息附带 1 个文件')).toBeVisible());
|
||||
});
|
||||
Reference in New Issue
Block a user