476 lines
32 KiB
TypeScript
476 lines
32 KiB
TypeScript
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 { CloudAgentJournal } from '@electron/services/cloud-agent-journal';
|
|
import { operationPlan } from '@electron/services/cloud-agent-operations';
|
|
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', () => { 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, 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 });
|
|
const session = () => json({
|
|
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)) {
|
|
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, journal()); instances.push(module); return module;
|
|
}
|
|
beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); });
|
|
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
|
|
|
|
describe('Main cloud Agents boundary', () => {
|
|
it('accepts a five-minute session from a clock one second ahead and caps local caching', async () => {
|
|
vi.useFakeTimers();
|
|
const now = Math.floor(Date.now() / 1000) * 1000;
|
|
vi.setSystemTime(now);
|
|
const transport = vi.fn(async (url: string) => url.endsWith('/session')
|
|
? json({ access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agents',
|
|
expires_at: Math.floor(Date.now() / 1000) + 301, api_base_url: 'https://agents.example.test' })
|
|
: json({ agents: [], next_cursor: null }));
|
|
const module = moduleFor(transport);
|
|
expect(await module.list()).toEqual({ agents: [], next_cursor: null });
|
|
expect(transport.mock.calls.map(([url]) => new URL(url).pathname))
|
|
.toEqual(['/api/cloud-agents/session', '/api/makelore/agents']);
|
|
vi.setSystemTime(now + 294000);
|
|
await module.list();
|
|
expect(transport.mock.calls.filter(([url]) => url.endsWith('/session'))).toHaveLength(1);
|
|
vi.setSystemTime(now + 295000);
|
|
await module.list();
|
|
expect(transport.mock.calls.filter(([url]) => url.endsWith('/session'))).toHaveLength(2);
|
|
});
|
|
|
|
it.each([0, -1, 1e308])('rejects an expired or invalid session timestamp %s before calling Yuxi', async (expiresAt) => {
|
|
const transport = vi.fn().mockResolvedValue(json({
|
|
access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agents',
|
|
expires_at: expiresAt, api_base_url: 'https://agents.example.test',
|
|
}));
|
|
await expect(moduleFor(transport).list()).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
expect(transport).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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 {
|
|
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('preserves knowledge readiness and processing fields through Main upload and attachment import', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'makelore-knowledge-status-'));
|
|
try {
|
|
const source = join(directory, 'replacement.txt');
|
|
await writeFile(source, 'knowledge content');
|
|
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
|
|
const file = { file_id: 'new-file', name: 'replacement.txt', size: 17, status: 'indexed', error: null,
|
|
chunk_count: 2, available: true, replaces_file_id: 'old-file',
|
|
processing_task: { task_id: 'task', status: 'success', error: null } };
|
|
const transport = vi.fn().mockResolvedValueOnce(session())
|
|
.mockResolvedValueOnce(json({ ...file, access_token: 'never-render' }))
|
|
.mockResolvedValueOnce(json({ ...file, access_token: 'never-render' }));
|
|
const module = moduleFor(transport);
|
|
const request = { slug: draft.slug, kb_id: 'kb_test', operation_id: input.operation_id };
|
|
expect(await module.uploadKnowledge({ ...request, replaces_file_id: 'old-file' })).toEqual(file);
|
|
expect(await module.execute({ operation: 'importKnowledgeAttachment', input: {
|
|
...request, thread_id: 'thread', attachment_id: 'attachment',
|
|
} })).toEqual(file);
|
|
expect(new URL(transport.mock.calls[1][0]).searchParams.get('replaces_file_id')).toBe('old-file');
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
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('defaults omitted execution steps to 300 while preserving an explicit saved limit', async () => {
|
|
const { max_execution_steps: _omitted, ...configurationWithoutSteps } = EMPTY_CLOUD_CONFIGURATION;
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
|
|
.mockResolvedValueOnce(json({ ...draft, configuration: { ...configurationWithoutSteps, max_execution_steps: 40 } }))
|
|
.mockResolvedValueOnce(json({ ...draft, configuration: configurationWithoutSteps }));
|
|
const module = moduleFor(fetchImpl);
|
|
expect((await module.get(draft.slug)).configuration.max_execution_steps).toBe(40);
|
|
expect((await module.get(draft.slug)).configuration.max_execution_steps).toBe(300);
|
|
});
|
|
|
|
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('does not expose removed channel policy, invitation, or caller-management operations', () => {
|
|
for (const operation of ['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']) {
|
|
expect(() => operationPlan({ operation, input: { slug: draft.slug, channel_account_id: 'wechat-1' } })).toThrow('invalid_operation');
|
|
}
|
|
});
|
|
|
|
it('discards a legacy removed-operation journal entry without replaying its API', async () => {
|
|
const accountKey = 'a'.repeat(64);
|
|
journalRecords.set(accountKey, { recent: null, pending: [{
|
|
id: 'channelPolicy:legacy', operation: 'channelPolicy', created_at: '2026-09-13T00:00:00Z',
|
|
input: { slug: draft.slug, channel_account_id: 'wechat-1', operation_id: input.operation_id, access_mode: 'invited' },
|
|
}] });
|
|
const fetchImpl = vi.fn();
|
|
const module = moduleFor(fetchImpl);
|
|
await expect(module.resolvePending({ id: 'channelPolicy:legacy' })).resolves.toMatchObject({
|
|
operation: 'channelPolicy', discarded: true, retired: true,
|
|
});
|
|
expect((await module.recovery()).pending).toEqual([]);
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the fixed self-only channel query and slug query aliases', () => {
|
|
const selfCallers = operationPlan({ operation: 'channelSelfCallers', input: { slug: draft.slug } });
|
|
expect(selfCallers.path).toBe(`/api/makelore/agents/${draft.slug}/channel-callers?access_mode=self_only`);
|
|
const conversations = operationPlan({ operation: 'channelConversations', input: { slug: draft.slug, offset: 12 } });
|
|
expect(conversations.path).toBe(`/api/makelore/channel-conversations?agent_slug=${draft.slug}&offset=12`);
|
|
const conversation = operationPlan({ operation: 'channelConversation', input: { session_id: 'session-1', offset: 100 } });
|
|
expect(conversation.path).toBe('/api/makelore/channel-conversations/session-1?offset=100');
|
|
const receipt = operationPlan({ operation: 'channelOperation', input: { slug: draft.slug, operation_id: 'op-123' } });
|
|
expect(receipt.path).toBe(`/api/makelore/channel-operations/op-123?agent_slug=${draft.slug}`);
|
|
expect(receipt.project({ operation_id: 'op-123', status: 'completed', result: { id: 'channel-1', account_key: 'private' } })).toEqual({
|
|
operation_id: 'op-123', status: 'completed', result: { id: 'channel-1' },
|
|
});
|
|
});
|
|
|
|
it('projects channel output without provider credentials or arbitrary QR URLs', () => {
|
|
const plan = operationPlan({ operation: 'channelBindings', input: { slug: draft.slug } });
|
|
expect(plan.project({ items: [{ id: 'channel-1', display_name: '个人微信', account_key: 'private', health: 'connected', blockers: ['publish_required', { secret: true }] }],
|
|
available_accounts: [{ id: 'channel-2', adoption_state: 'available', managed_agent_slug: null, access_token: 'private' }] })).toEqual({
|
|
items: [{ id: 'channel-1', display_name: '个人微信', health: 'connected', blockers: ['publish_required'] }],
|
|
available_accounts: [{ id: 'channel-2', adoption_state: 'available', managed_agent_slug: null }],
|
|
});
|
|
const qr = operationPlan({ operation: 'wechatBindStart', input: { slug: draft.slug, channel_account_id: 'channel-1', operation_id: input.operation_id, force: false } });
|
|
expect(qr.project({ session_key: 'session-1', status: 'pending', qrcode_url: 'http://provider.invalid/qr', access_token: 'private' })).toEqual({ session_key: 'session-1', status: 'pending' });
|
|
const activity = operationPlan({ operation: 'channelActivity', input: { slug: draft.slug, channel_account_id: 'channel-1', offset: 0, limit: 50 } });
|
|
expect(activity.project({ items: [{ status: 'completed', delivery: { logical_message_id: 'inbound-id', result: { message_id: 'result-id', provider_url: 'private' }, core: { parts: [{ part_id: 'file-1', type: 'file', adapter_status: 'failed', url: 'private' }] } }, private_prompt: 'hidden' }], next_offset: null })).toEqual({
|
|
items: [{ status: 'completed', delivery: { logical_message_id: 'result-id', result: { message_id: 'result-id' }, core: { parts: [{ part_id: 'file-1', type: 'file', adapter_status: 'failed' }] }, parts: [{ part_id: 'file-1', kind: 'file', status: 'failed' }] } }], next_offset: null,
|
|
});
|
|
});
|
|
|
|
it('preserves a failed channel run status, partial output, and authoritative error', () => {
|
|
const plan = operationPlan({ operation: 'channelConversation', input: { session_id: 'session-1' } });
|
|
expect(plan.project({
|
|
session_id: 'session-1', thread_id: 'thread-1', messages: [], queued_requests: [], next_offset: null,
|
|
run: { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', agent_slug: draft.slug,
|
|
status: 'failed', output: 'PPT 只写入了一部分。', version: '1',
|
|
error: { type: 'execution_step_limit', message: '本次运行已达到执行步数上限,尚未完成。可在智能体设置中提高上限后重试。' } },
|
|
})).toEqual({
|
|
session_id: 'session-1', thread_id: 'thread-1', messages: [], queued_requests: [], next_offset: null,
|
|
run: { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', agent_slug: draft.slug,
|
|
status: 'failed', output: 'PPT 只写入了一部分。', version: '1',
|
|
error: { type: 'execution_step_limit', message: '本次运行已达到执行步数上限,尚未完成。可在智能体设置中提高上限后重试。' } },
|
|
});
|
|
});
|
|
|
|
it('routes user-level channel accounts without requiring an Agent slug', () => {
|
|
const list = operationPlan({ operation: 'channelAccounts', input: {} });
|
|
expect(list.path).toBe('/api/makelore/channel-accounts');
|
|
expect(list.project({ items: [{ id: 'wechat-1', display_name: '工作微信', revision: 2, enabled: false, status: 'paused', health: 'connected',
|
|
blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, target_agent_published_version: null, access_token: 'private' }] })).toEqual({
|
|
items: [{ id: 'wechat-1', display_name: '工作微信', revision: 2, enabled: false, status: 'paused', health: 'connected',
|
|
blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, target_agent_published_version: null }],
|
|
});
|
|
const route = operationPlan({ operation: 'routeChannelAccount', input: { channel_account_id: 'wechat-1', operation_id: 'op-route',
|
|
target_agent_slug: draft.slug, expected_revision: 2, enabled: true } });
|
|
expect(route.path).toBe('/api/makelore/channel-accounts/wechat-1/route');
|
|
expect(route.body).toEqual({ operation_id: 'op-route', target_agent_slug: draft.slug, expected_revision: 2, enabled: true });
|
|
expect(() => operationPlan({ operation: 'routeChannelAccount', input: { channel_account_id: 'wechat-1', operation_id: 'op-route',
|
|
target_agent_slug: null, expected_revision: 2, enabled: true } })).toThrow('invalid_input');
|
|
expect(operationPlan({ operation: 'channelAccountOperation', input: { operation_id: 'op-route' } }).path)
|
|
.toBe('/api/makelore/channel-operations/op-route');
|
|
});
|
|
|
|
it('recovers a completed WeChat verification from its receipt without replaying the code', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
|
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
|
|
const module = moduleFor(fetchImpl);
|
|
const verification = { slug: draft.slug, channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' };
|
|
await expect(module.execute({ operation: 'wechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
const raw = JSON.stringify(Array.from(journalRecords.values()));
|
|
expect(raw).not.toContain('secret-code-123');
|
|
const pending = (await module.recovery()).pending[0];
|
|
expect(pending.input).not.toHaveProperty('verify_code');
|
|
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ operation: 'wechatBindVerification', result: { status: 'completed' } });
|
|
expect((await module.recovery()).pending).toEqual([]);
|
|
expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}?agent_slug=${draft.slug}`);
|
|
});
|
|
|
|
it('recovers user-level WeChat verification without an Agent slug', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
|
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
|
|
const module = moduleFor(fetchImpl);
|
|
const verification = { channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' };
|
|
await expect(module.execute({ operation: 'channelAccountWechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
const pending = (await module.recovery()).pending[0];
|
|
expect(pending.input).not.toHaveProperty('verify_code');
|
|
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ operation: 'channelAccountWechatBindVerification', result: { status: 'completed' } });
|
|
expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}`);
|
|
expect(fetchImpl.mock.calls[2][0]).not.toContain('agent_slug');
|
|
});
|
|
|
|
it('asks for a new WeChat verification code only when its receipt is absent', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
|
.mockResolvedValueOnce(json({ detail: { code: 'operation_not_found' } }, 404));
|
|
const module = moduleFor(fetchImpl);
|
|
const verification = { slug: draft.slug, channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' };
|
|
await expect(module.execute({ operation: 'wechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
const pending = (await module.recovery()).pending[0];
|
|
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ requires_input: true, operation: 'wechatBindVerification' });
|
|
});
|
|
|
|
it('keeps delivery-part recovery records distinct for different messages and parts', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValue(new Error('lost response'));
|
|
const module = moduleFor(fetchImpl);
|
|
const first = { slug: draft.slug, channel_account_id: 'channel-a', logical_message_id: 'message-a', part_id: 'part-a' };
|
|
const second = { slug: draft.slug, channel_account_id: 'channel-b', logical_message_id: 'message-b', part_id: 'part-b' };
|
|
await expect(module.execute({ operation: 'retryChannelDeliveryPart', input: first })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
await expect(module.execute({ operation: 'retryChannelDeliveryPart', input: second })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
const pending = (await module.recovery()).pending;
|
|
expect(pending).toHaveLength(2);
|
|
expect(pending.map(item => item.input)).toEqual(expect.arrayContaining([first, second]));
|
|
expect(new Set(pending.map(item => item.id)).size).toBe(2);
|
|
expect(pending.map(item => item.id).join('\n')).toContain('channel-a:message-a:part-a');
|
|
expect(pending.map(item => item.id).join('\n')).toContain('channel-b:message-b:part-b');
|
|
});
|
|
|
|
it('downloads personal channel artifacts through the dedicated session route', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'makelore-channel-files-'));
|
|
try {
|
|
const destination = join(directory, 'channel-result.txt');
|
|
desktop.save.mockResolvedValue({ canceled: false, filePath: destination });
|
|
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(new Response('channel result'));
|
|
const module = moduleFor(transport);
|
|
await expect(module.downloadChannelArtifact({ session_id: 'session-1', path: '/outputs/channel-result.txt' })).resolves.toEqual({ saved: true });
|
|
expect(await readFile(destination, 'utf8')).toBe('channel result');
|
|
expect(transport.mock.calls[1][0]).toContain('/api/makelore/channel-conversations/session-1/artifacts/outputs/channel-result.txt?download=true');
|
|
expect(transport.mock.calls[1][0]).not.toContain('/threads/');
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
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' }))
|
|
.mockResolvedValueOnce(json({ agents: [draft], next_cursor: null }));
|
|
const module = moduleFor(fetchImpl);
|
|
expect(await module.create({ ...input, payer: 'other', account_id: 'other' })).toEqual(draft);
|
|
expect(await module.list()).toEqual({ agents: [draft], next_cursor: null });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret');
|
|
expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer yuxi-secret');
|
|
expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual(input);
|
|
expect(fetchImpl.mock.calls[1][1].redirect).toBe('error');
|
|
});
|
|
|
|
it('rejects the old account response after switching accounts', async () => {
|
|
let finish!: (response: Response) => void;
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>((resolve) => { finish = resolve; }));
|
|
const result = moduleFor(fetchImpl).list();
|
|
const rejection = expect(result).rejects.toMatchObject({ code: 'account_changed' });
|
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
|
|
login('b'.repeat(64));
|
|
finish(json({ agents: [draft], next_cursor: null }));
|
|
await rejection;
|
|
});
|
|
|
|
it('does not automatically repeat a possibly committed create and preserves an explicit retry operation', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response ws-secret'))
|
|
.mockResolvedValueOnce(json(draft));
|
|
const module = moduleFor(fetchImpl);
|
|
await expect(module.create(input)).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
expect(await module.create(input)).toEqual(draft);
|
|
expect(fetchImpl.mock.calls[2][1].body).toBe(fetchImpl.mock.calls[1][1].body);
|
|
});
|
|
|
|
it('passes revision conflicts without exposing upstream diagnostics', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
|
|
detail: { code: 'draft_revision_conflict', message: 'raw yuxi-secret' },
|
|
}, 409));
|
|
await expect(moduleFor(fetchImpl).save(draft.slug, {
|
|
expected_revision: 1, name: draft.name, purpose: draft.purpose, system_prompt: 'new',
|
|
})).rejects.toMatchObject({ status: 409, code: 'draft_revision_conflict', message: '草稿已在其他设备更新,你的输入已保留' });
|
|
});
|
|
|
|
it('rejects malformed draft responses as service failures', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, draft_revision: 'oops' }));
|
|
await expect(moduleFor(fetchImpl).get(draft.slug)).rejects.toMatchObject({ status: 502 });
|
|
});
|
|
|
|
it('rejects missing login before any network request', async () => {
|
|
clearWorksSquareSession();
|
|
const fetchImpl = vi.fn();
|
|
await expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ status: 401 });
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('times out a stalled response body and projects a safe error', async () => {
|
|
vi.useFakeTimers();
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce({ text: () => new Promise(() => {}) });
|
|
const result = expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
await result;
|
|
});
|
|
});
|