feat(agents): 支持个人微信发布与渠道会话
This commit is contained in:
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -171,6 +172,89 @@ describe('Main cloud Agents boundary', () => {
|
||||
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('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('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 } }))
|
||||
|
||||
Reference in New Issue
Block a user