Files
makelore/tests/unit/cloud-agents-main.test.ts

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;
});
});