Simplify Agent chat with inline attachments and separate thinking
This commit is contained in:
1 parent
7af43acebb
commit
faebfe9e84
18 files changed
+1558
-195
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test('own WeChat conversation approval and generated PPT delivery in the desktop workspace', async ({ launchElectronApp }, testInfo) => {
|
||||
test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const agentSlug = 'ml-' + 'a'.repeat(32);
|
||||
const secondAgentSlug = 'ml-' + 'b'.repeat(32);
|
||||
@@ -13,14 +13,12 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
BrowserWindow.getAllWindows()[0].setSize(1366, 900);
|
||||
const calls: { operation: string; input: unknown }[] = [];
|
||||
Object.assign(globalThis, { cloudChannelFixtureCalls: calls });
|
||||
let approved = false;
|
||||
let verified = false;
|
||||
const slug = 'ml-' + 'a'.repeat(32);
|
||||
const configuration = { model: 'test-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 draft = { slug, name: '我的创作助手', purpose: '整理选题与制作演示文稿', system_prompt: '使用简洁自然的中文', draft_revision: 2, updated_at: '2026-09-13T00:00:00Z', configuration, published_version: 1, enabled: true, archived: false };
|
||||
const secondSlug = 'ml-' + 'b'.repeat(32);
|
||||
const secondDraft = { ...draft, slug: secondSlug, name: '资料整理助手', draft_revision: 1, published_version: 1 };
|
||||
const session = { session_id: 'self-session', caller_id: 'self-caller', agent_slug: slug, agent_name: draft.name, binding_id: 'binding-a', provider_generation: 'generation-a', channel_account_id: 'wechat-a', access_mode: 'self_only', grant_state: 'authorized', session_state: 'active', sequence: 1, thread_id: 'channel-thread', created_at: '2026-09-13T01:00:00Z' };
|
||||
const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0, target_agent_slug: slug, target_agent_name: draft.name, target_agent_published_version: 1 };
|
||||
const spare = { id: 'wechat-b', address: 'wechat:spare', display_name: '备用微信', target_agent_address: null, route_revision: 1, provider_generation: 'generation-b', binding_id: 'binding-b', enabled: false, status: 'paused', worker_online: true, agent_slug: null, published_version: null, desired_state: 'disabled', sync_state: 'ready', health: 'login_required', last_confirmed_at: null, revision: 1, blockers: ['target_required'], access_mode: 'self_only', policy_revision: 0, target_agent_slug: null, target_agent_name: null, target_agent_published_version: null };
|
||||
let channelAccounts = [channel, spare];
|
||||
@@ -35,7 +33,6 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/recovery/recent')) return result({ saved: true });
|
||||
if (path.endsWith('/files/save-channel')) { calls.push({ operation: 'downloadChannelArtifact', input: body }); return result({ saved: true }); }
|
||||
if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft, secondDraft], next_cursor: null } : path.endsWith(secondSlug) ? secondDraft : draft);
|
||||
const { operation, input = {} } = body;
|
||||
calls.push({ operation, input });
|
||||
@@ -70,18 +67,8 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), disconnected: true });
|
||||
}
|
||||
if (operation === 'channelBindings') return result({ items: channelAccounts.filter(item => item.target_agent_slug === input.slug), available_accounts: [] });
|
||||
if (operation === 'channelConversations') return result({ items: [session], next_offset: null });
|
||||
if (operation === 'channelSelfCallers') return result({ items: [{ ...session, paired_ws_account_id: 'creator', revoked_at: null }] });
|
||||
if (operation === 'channelActivity') return result({ items: [], next_offset: null });
|
||||
if (operation === 'wechatBindStart' || operation === 'wechatBindStatus' || operation === 'channelAccountWechatBindStart' || operation === 'channelAccountWechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' });
|
||||
if (operation === 'wechatBindVerification' || operation === 'channelAccountWechatBindVerification') { verified = true; channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'connected' } : item); return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); }
|
||||
if (operation === 'channelConversation') return result({ ...session, messages: [{ id: 1, role: 'user', content: '把今天的创作建议整理成演示文稿。' }, ...(approved ? [{ id: 2, role: 'assistant', content: '## 演示文稿已完成\n共 7 页,文件已保存在本次会话中。' }] : [])], queued_requests: [], next_offset: null,
|
||||
run: { agent_run_id: 'channel-run', request_id: 'channel-request', thread_id: 'channel-thread', agent_slug: slug, status: approved ? 'completed' : 'interrupted', output: '', version: '1',
|
||||
...(approved ? {} : { interrupt: { approval: { action_requests: [{ name: 'execute', args: { command: 'python create_presentation.py' } }] } } }) } });
|
||||
if (operation === 'channelConversationControl') { approved = true; return result({ session_id: session.session_id, operation_id: input.operation_id, action: input.action, status: 'accepted', run_id: 'channel-run' }); }
|
||||
if (operation === 'channelFiles') return result({ session_id: session.session_id, thread_id: session.thread_id, files: !approved ? [] : input.path === '/'
|
||||
? [{ name: 'outputs', path: '/outputs', directory_path: '/outputs', is_dir: true, size: 0 }]
|
||||
: [{ name: '创作建议.pptx', path: '/outputs/创作建议.pptx', directory_path: '/outputs', is_dir: false, size: 40960 }] });
|
||||
if (operation === 'channelAccountWechatBindStart' || operation === 'channelAccountWechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' });
|
||||
if (operation === 'channelAccountWechatBindVerification') { verified = true; channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'connected' } : item); return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); }
|
||||
if (operation === 'schedules') return result({ jobs: [] });
|
||||
if (operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史', payer: 'creator' });
|
||||
throw new Error('Unexpected channel UI fixture operation ' + operation);
|
||||
@@ -90,20 +77,12 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
await page.goto(applicationUrl);
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
await page.getByRole('article', { name: '我的创作助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click();
|
||||
await page.getByRole('button', { name: '微信对话', exact: true }).click();
|
||||
const own = page.getByRole('region', { name: '微信会话', exact: true });
|
||||
await expect(own.getByTestId('wechat-run-status')).toContainText('任务状态:等待你的确认');
|
||||
await own.getByRole('button', { name: '允许执行', exact: true }).click();
|
||||
await expect(own.getByRole('heading', { name: '演示文稿已完成' })).toBeVisible();
|
||||
await expect(own.getByTestId('wechat-run-status')).toContainText('任务状态:已完成');
|
||||
await own.getByRole('button', { name: '保存 创作建议.pptx', exact: true }).click();
|
||||
await expect(own.getByText('已保存 创作建议.pptx', { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-own-deliverables.png') });
|
||||
const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls);
|
||||
expect(calls.find(call => call.operation === 'channelConversationControl')?.input).toMatchObject({ session_id: 'self-session', run_id: 'channel-run', action: 'resume', decision: { decisions: [{ type: 'approve' }] } });
|
||||
expect(calls.find(call => call.operation === 'downloadChannelArtifact')?.input).toEqual({ session_id: 'self-session', path: '/outputs/创作建议.pptx' });
|
||||
expect(calls.some(call => call.operation === 'resume' || call.operation === 'submit')).toBe(false);
|
||||
await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '我的创作助手', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('navigation', { name: '对话来源', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('region', { name: '微信会话', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '返回智能体列表', exact: true }).click();
|
||||
await page.getByRole('button', { name: '渠道', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: '连接到微信', exact: true })).toBeVisible();
|
||||
const legacyCard = page.getByRole('article', { name: '创作微信微信', exact: true });
|
||||
@@ -113,7 +92,6 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
|
||||
const callsBeforeConfig = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.length);
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-binding-cards.png'), fullPage: true });
|
||||
|
||||
await page.getByRole('button', { name: '添加微信', exact: true }).click();
|
||||
@@ -146,6 +124,8 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
await switching.getByRole('button', { name: /确认更换/ }).click();
|
||||
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls
|
||||
.filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-a' && call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) && call.input.enabled === true))).toBe(true);
|
||||
await expect(managing.getByText('已更换绑定的智能体,微信连接保留。', { exact: true })).toBeVisible();
|
||||
await expect(managing.getByLabel('绑定的智能体', { exact: true })).toHaveValue(secondAgentSlug);
|
||||
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length)).toBe(qrStartsBeforeSwitch);
|
||||
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
|
||||
@@ -153,9 +133,19 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });
|
||||
await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-manage-binding-compact.png'), fullPage: true });
|
||||
const finalCalls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls);
|
||||
expect(finalCalls.slice(callsBeforeConfig).some(call => ['channelActivity', 'channelConversations', 'channelConversation', 'channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller'].includes(call.operation))).toBe(false);
|
||||
await managing.getByRole('button', { name: '关闭', exact: true }).click();
|
||||
await expect(managing).toHaveCount(0);
|
||||
await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible();
|
||||
const finalCalls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls);
|
||||
expect(finalCalls.some(call => ['channelActivity', 'channelConversations', 'channelConversation', 'channelConversationControl', 'channelFiles', 'downloadChannelArtifact', 'channelPolicy', 'createChannelPairing', 'channelCallers', 'channelSelfCallers', 'revokeChannelCaller', 'submit', 'preview', 'resume'].includes(call.operation))).toBe(false);
|
||||
expect(finalCalls.filter(call => call.operation === 'createChannelAccount')).toHaveLength(1);
|
||||
expect(finalCalls.find(call => call.operation === 'createChannelAccount')?.input).toEqual({ display_name: '新创作微信', operation_id: expect.any(String) });
|
||||
expect(finalCalls.find(call => call.operation === 'channelAccountWechatBindVerification')?.input).toEqual({ channel_account_id: 'wechat-created', session_key: 'qr-session', verify_code: '123456', operation_id: expect.any(String) });
|
||||
expect(finalCalls.find(call => call.operation === 'enableChannelAccount')?.input).toEqual({ channel_account_id: 'wechat-created', expected_revision: 2, operation_id: expect.any(String) });
|
||||
expect(finalCalls.filter(call => call.operation === 'routeChannelAccount').map(call => call.input)).toEqual([
|
||||
{ channel_account_id: 'wechat-created', target_agent_slug: agentSlug, expected_revision: 1, enabled: false, operation_id: expect.any(String) },
|
||||
{ channel_account_id: 'wechat-a', target_agent_slug: secondAgentSlug, expected_revision: 3, enabled: true, operation_id: expect.any(String) },
|
||||
]);
|
||||
|
||||
} finally {
|
||||
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
import type { CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents';
|
||||
import type { CloudAttachment, CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents';
|
||||
|
||||
let server: ViteDevServer;
|
||||
let baseUrl: string;
|
||||
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
|
||||
|
||||
// The real CloudChat, Markdown renderer and CloudFiles are used. Main IPC and
|
||||
// The real CloudChat, Markdown renderer and attachment composer are used. Main IPC and
|
||||
// the event stream are isolated fixtures; this suite never starts a native app
|
||||
// or contacts an account, cloud service or model.
|
||||
test.beforeAll(async () => {
|
||||
@@ -25,15 +25,13 @@ test.beforeAll(async () => {
|
||||
if (id === entry) return `
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx';
|
||||
import { Button } from '/src/components/ui/button.tsx';
|
||||
import avatar from '/src/assets/agent-avatars/avatar-05.png';
|
||||
import '/src/styles/globals.css';
|
||||
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<main className="flex h-screen min-h-0 min-w-0 flex-col">
|
||||
<CloudChat slug="ml-chat-layout-fixture" assistantName="我的故事搭档" assistantAvatarUrl={avatar}
|
||||
examples={examples} headerAction={<Button variant="outline" data-testid="fixture-adjust"
|
||||
onClick={() => window.__cloudChatFixture.adjustments++}>调整智能体</Button>} />
|
||||
examples={examples} />
|
||||
</main>
|
||||
);
|
||||
`;
|
||||
@@ -91,31 +89,42 @@ function makeHistory(thread: CloudThread, dense = false, run: CloudRun | null =
|
||||
|
||||
type FixtureCall = { path: string; method: string; input: Record<string, unknown> | null };
|
||||
type FixtureSeed = { threads: CloudThread[]; histories: Record<string, CloudHistory> };
|
||||
type FixtureState = FixtureSeed & { calls: FixtureCall[]; adjustments: number };
|
||||
type FixtureStream = EventTarget & { readyState: number; url: string };
|
||||
type FixtureState = FixtureSeed & { calls: FixtureCall[]; attachments: CloudAttachment[]; streams: FixtureStream[] };
|
||||
type FixtureWindow = Window & { __cloudChatFixture: FixtureState };
|
||||
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking';
|
||||
|
||||
async function installFixture(page: Page, kind: 'empty' | 'history' | 'running' | 'approval') {
|
||||
const first = makeThread('story-first', '小狐狸的故事', kind === 'running' ? 'running' : kind === 'approval' ? 'interrupted' : 'completed');
|
||||
async function installFixture(page: Page, kind: FixtureKind) {
|
||||
const isRunning = kind === 'running' || kind === 'thinking';
|
||||
const first = makeThread('story-first', '小狐狸的故事', isRunning ? 'running' : kind === 'approval' ? 'interrupted' : 'completed');
|
||||
const second = makeThread('story-second', '另一段故事');
|
||||
const interrupt: CloudInterrupt = { approval: { action_requests: [{ name: 'execute',
|
||||
description: '把刚才确认的故事保存到我的故事笔记', args: { path: '/stories/' + longWord, content: '保留我们的故事想法。' } }] } };
|
||||
const run = kind === 'running' ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null;
|
||||
const run = isRunning ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null;
|
||||
const seed: FixtureSeed = kind === 'empty' ? { threads: [], histories: {} } : {
|
||||
threads: [first, second], histories: {
|
||||
[first.thread_id]: makeHistory(first, kind !== 'approval', run),
|
||||
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] } : makeHistory(first, kind !== 'approval', run),
|
||||
[second.thread_id]: makeHistory(second),
|
||||
},
|
||||
};
|
||||
await page.addInitScript(({ seed }) => {
|
||||
const scope = window as unknown as FixtureWindow;
|
||||
scope.__cloudChatFixture = { ...seed, calls: [], adjustments: 0 };
|
||||
scope.__cloudChatFixture = { ...seed, calls: [], attachments: [{ file_id: 'attachment-story', file_name: '我的故事笔记与新发现.txt', file_size: 4096 }], streams: [] };
|
||||
const state = scope.__cloudChatFixture;
|
||||
const persisted = sessionStorage.getItem('cloud-chat-fixture-durable');
|
||||
if (persisted) {
|
||||
const { history, thread } = JSON.parse(persisted) as { history: CloudHistory; thread: CloudThread };
|
||||
state.histories[thread.thread_id] = history;
|
||||
state.threads[0] = thread;
|
||||
}
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } });
|
||||
// Keep durable run polling real, but prevent EventSource from reaching a network.
|
||||
class FixtureEventSource extends EventTarget {
|
||||
readyState = 1;
|
||||
url: string;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
constructor(url: string) { super(); this.url = url; state.streams.push(this); }
|
||||
close() { this.readyState = 2; }
|
||||
}
|
||||
Object.defineProperty(window, 'EventSource', { value: FixtureEventSource });
|
||||
@@ -130,6 +139,7 @@ async function installFixture(page: Page, kind: 'empty' | 'history' | 'running'
|
||||
state.calls.push({ path, method, input });
|
||||
if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: [] });
|
||||
if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true });
|
||||
if (path === '/api/cloud-agents/attachments/pick') return result({ object_name: 'temporary/狐狸照片.png', file_name: '狐狸照片.png', file_type: 'png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
|
||||
if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path: ' + path);
|
||||
const operation = String(input?.operation);
|
||||
const args = input?.input as Record<string, unknown> | undefined;
|
||||
@@ -139,8 +149,13 @@ async function installFixture(page: Page, kind: 'empty' | 'history' | 'running'
|
||||
case 'threads': return result({ threads: state.threads, next_offset: null });
|
||||
case 'history': return result(history);
|
||||
case 'viewed': return result({ viewed: true });
|
||||
case 'attachments': return result({ attachments: [{ file_id: 'attachment-story', file_name: '我的故事笔记与新发现.txt', file_size: 4096 }] });
|
||||
case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md' }] });
|
||||
case 'attachments': return result({ attachments: state.attachments });
|
||||
case 'confirmAttachment': {
|
||||
const attachment = { file_id: 'attachment-photo', file_name: '狐狸照片.png', file_size: 8192 };
|
||||
if (!state.attachments.some(item => item.file_id === attachment.file_id)) state.attachments.push(attachment);
|
||||
return result({ attachments: [attachment] });
|
||||
}
|
||||
case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md', size: 1024 }] });
|
||||
case 'run': return result(Object.values(state.histories).find(item => item.run?.agent_run_id === args?.run_id)?.run);
|
||||
case 'cancelRun': return result({ status: 'cancel_requested' });
|
||||
case 'resume': {
|
||||
@@ -160,7 +175,7 @@ async function installFixture(page: Page, kind: 'empty' | 'history' | 'running'
|
||||
});
|
||||
}
|
||||
|
||||
async function openChat(page: Page, kind: 'empty' | 'history' | 'running' | 'approval', width: number, height: number) {
|
||||
async function openChat(page: Page, kind: FixtureKind, width: number, height: number) {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.setViewportSize({ width, height });
|
||||
@@ -169,6 +184,10 @@ async function openChat(page: Page, kind: 'empty' | 'history' | 'running' | 'app
|
||||
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('消息', { exact: true })).toBeEnabled();
|
||||
await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('.agent-chat-toolbar')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^(已保存|已应用|调整智能体|查看历史对话|新对话|客户端对话|微信对话)$/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('navigation', { name: '对话来源', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/由创建者支付词元点数|和助手聊一聊/)).toHaveCount(0);
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -202,6 +221,14 @@ async function screenshot(page: Page, name: string) {
|
||||
await test.info().attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
async function emitRunEvent(page: Page, type: 'messages' | 'end', id: string, envelope: Record<string, unknown>) {
|
||||
await page.evaluate(({ type, id, envelope }) => {
|
||||
const stream = (window as unknown as FixtureWindow).__cloudChatFixture.streams.filter(item => item.readyState === 1).at(-1);
|
||||
if (!stream) throw new Error('Missing active fixture stream');
|
||||
stream.dispatchEvent(new MessageEvent(type, { lastEventId: id, data: JSON.stringify(envelope) }));
|
||||
}, { type, id, envelope });
|
||||
}
|
||||
|
||||
for (const width of [375, 768, 1024, 1440]) {
|
||||
test(`friendly empty conversation keeps the composer reachable at ${width}px`, async ({ page }) => {
|
||||
const errors = await openChat(page, 'empty', width, width === 375 ? 640 : 760);
|
||||
@@ -228,6 +255,13 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900
|
||||
const transcript = page.getByTestId('cloud-chat-messages');
|
||||
const composer = page.getByTestId('cloud-chat-composer');
|
||||
await expect(transcript.getByRole('article')).toHaveCount(16);
|
||||
const userBubble = transcript.locator('.agent-chat-message[data-role="user"]').first();
|
||||
const bubbleStyle = await userBubble.evaluate(element => {
|
||||
const style = getComputedStyle(element);
|
||||
return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) };
|
||||
});
|
||||
expect(bubbleStyle.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(bubbleStyle.radius).toBeGreaterThan(0);
|
||||
await expect(transcript.locator('pre')).toHaveCount(1);
|
||||
await expect(transcript.locator('table')).toHaveCount(1);
|
||||
await expectComposerBounds(page);
|
||||
@@ -244,26 +278,58 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900
|
||||
const input = page.getByLabel('消息', { exact: true });
|
||||
await input.fill(Array.from({ length: 6 }, (_, index) => `第 ${index + 1} 行未发送的故事想法`).join('\n'));
|
||||
await expectComposerBounds(page);
|
||||
await page.getByRole('button', { name: '查看历史对话', exact: true }).click();
|
||||
const history = page.getByLabel('历史对话', { exact: true });
|
||||
await expect(history).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: '查看历史对话', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
|
||||
await expect(transcript.getByRole('article')).toHaveCount(16);
|
||||
await input.fill('');
|
||||
await expect(history).toBeEnabled();
|
||||
await history.selectOption('story-second');
|
||||
await expect(transcript.getByText('这是另一段保留下来的故事对话。', { exact: true })).toBeVisible();
|
||||
await expect(transcript.getByRole('article')).toHaveCount(1);
|
||||
await page.locator('.agent-chat-files summary').click();
|
||||
await expect(page.getByRole('button', { name: '添加附件', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '对话文件', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '添加文件或图片', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible();
|
||||
const chosen = page.getByRole('checkbox', { name: '选择附件 我的故事笔记与新发现.txt', exact: true });
|
||||
await chosen.check();
|
||||
await expect(composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true })).toBeVisible();
|
||||
await composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true }).click();
|
||||
await expect(chosen).not.toBeChecked();
|
||||
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
|
||||
expect((await operations(page)).some(item => item?.operation === 'deleteAttachment')).toBe(false);
|
||||
await page.getByRole('button', { name: '对话文件', exact: true }).click();
|
||||
await expectComposerBounds(page);
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [375, 1440]) {
|
||||
test(`a picked image is added inline and can be deselected without deleting it at ${width}px`, async ({ page }) => {
|
||||
const errors = await openChat(page, 'history', width, width === 375 ? 640 : 900);
|
||||
const composer = page.getByTestId('cloud-chat-composer');
|
||||
const send = composer.getByRole('button', { name: '发送', exact: true });
|
||||
await expect(send).toBeDisabled();
|
||||
await composer.getByRole('button', { name: '添加文件或图片', exact: true }).click();
|
||||
const remove = composer.getByRole('button', { name: '移除附件 狐狸照片.png', exact: true });
|
||||
await expect(remove).toBeVisible();
|
||||
await expect(composer.locator('.agent-attachment-card[data-kind="image"]')).toHaveCount(1);
|
||||
await expect(composer.locator('.agent-attachment-card')).toHaveText(/狐狸照片\.png/);
|
||||
await expect(send).toBeEnabled();
|
||||
const confirms = (await operations(page)).filter(item => item?.operation === 'confirmAttachment');
|
||||
expect(confirms).toHaveLength(1);
|
||||
expect(confirms[0]?.input).toEqual({ thread_id: 'story-first', attachments: [{ object_name: 'temporary/狐狸照片.png', file_type: 'png' }] });
|
||||
expect((await operations(page)).some(item => item?.operation === 'parseAttachment')).toBe(false);
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, `agent-chat-inline-image-${width}`);
|
||||
await remove.click();
|
||||
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
|
||||
await expect(send).toBeDisabled();
|
||||
await composer.getByRole('button', { name: '对话文件', exact: true }).click();
|
||||
await expect(composer.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked();
|
||||
expect((await operations(page)).some(item => ['deleteAttachment', 'submit', 'preview'].includes(String(item?.operation)))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('running conversation keeps Stop reachable on a short narrow screen', async ({ page }) => {
|
||||
const errors = await openChat(page, 'running', 375, 540);
|
||||
const stop = page.getByRole('button', { name: '停止', exact: true });
|
||||
const stop = page.getByRole('button', { name: '停止回答', exact: true });
|
||||
await expect(stop).toBeInViewport({ ratio: 1 });
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, 'agent-chat-running-375');
|
||||
@@ -273,6 +339,92 @@ test('running conversation keeps Stop reachable on a short narrow screen', async
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) {
|
||||
test(`reasoning streams separately and becomes collapsed durable history at ${viewport.width}×${viewport.height}`, async ({ page }) => {
|
||||
const errors = await openChat(page, 'thinking', viewport.width, viewport.height);
|
||||
const transcript = page.getByTestId('cloud-chat-messages');
|
||||
const thinking = page.getByTestId('cloud-chat-reasoning');
|
||||
const envelope = { thread_id: 'story-first', run_id: 'run-story-first' };
|
||||
const prefix = '先观察故事里的小发现。\n';
|
||||
const reasoning = prefix + Array.from({ length: 40 }, (_, index) =>
|
||||
`第 ${index + 1} 个线索:${longWord},再想一想角色会怎样解决。`).join('\n');
|
||||
const answer = '小狐狸发现了会发光的故事笔记,决定邀请伙伴一起探索。';
|
||||
const delta = (fields: Record<string, unknown>) => ({ stream_event: {
|
||||
type: 'message_delta', message_id: 'assistant-story-first', ...fields,
|
||||
} });
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.streams.length)).toBe(1);
|
||||
await expect(thinking).toHaveCount(0);
|
||||
|
||||
// Child Agent and unrelated run events never become visible in this chat.
|
||||
for (const wrong of [{ thread_id: 'child-story', run_id: envelope.run_id }, { ...envelope, run_id: 'run-other' }]) {
|
||||
await emitRunEvent(page, 'messages', '1000-0', { ...wrong, payload: { items: [delta({ reasoning_content: '不属于当前对话的思考' })] } });
|
||||
}
|
||||
await expect(thinking).toHaveCount(0);
|
||||
|
||||
await emitRunEvent(page, 'messages', '1001-0', { ...envelope, payload: { items: [delta({ reasoning_content: prefix })] } });
|
||||
await expect(thinking).toHaveAttribute('open');
|
||||
await expect(thinking).toContainText(prefix.trim());
|
||||
await expect(page.locator('.agent-chat-welcome')).toHaveCount(0);
|
||||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(0);
|
||||
|
||||
// Replayed SSE IDs must not duplicate the thought or replace the answer.
|
||||
await emitRunEvent(page, 'messages', '1001-0', { ...envelope, payload: { items: [delta({ reasoning_content: '重复事件不应显示' })] } });
|
||||
await emitRunEvent(page, 'messages', '1002-0', { ...envelope, payload: { chunk: delta({
|
||||
additional_reasoning_content: reasoning.slice(prefix.length),
|
||||
}) } });
|
||||
await emitRunEvent(page, 'messages', '1003-0', { ...envelope, payload: { items: [delta({ content: answer })] } });
|
||||
await expect(thinking).toContainText(reasoning);
|
||||
await expect(transcript).not.toContainText('重复事件不应显示');
|
||||
await expect(transcript).not.toContainText('不属于当前对话的思考');
|
||||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
|
||||
expect(await thinking.locator('pre, table, a').count()).toBe(0);
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, `agent-chat-reasoning-live-${viewport.width}`);
|
||||
|
||||
const durable = await page.evaluate(({ reasoning, answer, date }) => {
|
||||
const state = (window as unknown as FixtureWindow).__cloudChatFixture;
|
||||
const history = state.histories['story-first'];
|
||||
if (!history.run) throw new Error('Missing reasoning fixture run');
|
||||
history.run = { ...history.run, status: 'completed', output: answer };
|
||||
history.messages = [{ id: 1, role: 'assistant', content: answer, reasoning_content: reasoning,
|
||||
request_id: history.run.request_id, run_id: history.run.agent_run_id, created_at: date }];
|
||||
state.threads[0] = { ...state.threads[0], status: 'completed' };
|
||||
return { history, thread: state.threads[0] };
|
||||
}, { reasoning, answer, date });
|
||||
// Native terminal events can reuse the last message ID and still finish a run.
|
||||
await emitRunEvent(page, 'end', '1003-0', { ...envelope, payload: { chunk: { status: 'completed' } } });
|
||||
await expect(page.getByRole('button', { name: '停止回答', exact: true })).toHaveCount(0);
|
||||
await expect(thinking).toHaveCount(1);
|
||||
await expect(thinking).not.toHaveAttribute('open');
|
||||
await expect(transcript.getByRole('article')).toHaveCount(1);
|
||||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
|
||||
await thinking.locator('summary').scrollIntoViewIfNeeded();
|
||||
await thinking.locator('summary').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(thinking).toHaveAttribute('open');
|
||||
await expect(thinking).toContainText(reasoning);
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, `agent-chat-reasoning-history-${viewport.width}`);
|
||||
await thinking.locator('summary').click();
|
||||
await expect(thinking).not.toHaveAttribute('open');
|
||||
|
||||
// Reopening restores the durable conversation without bringing back history controls.
|
||||
await page.evaluate(saved => sessionStorage.setItem('cloud-chat-fixture-durable', JSON.stringify(saved)), durable);
|
||||
await page.reload();
|
||||
await expect(thinking).toHaveCount(1);
|
||||
await expect(thinking).not.toHaveAttribute('open');
|
||||
await expect(page.getByRole('button', { name: '查看历史对话', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
|
||||
await thinking.locator('summary').click();
|
||||
await expect(thinking).toContainText(reasoning);
|
||||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
|
||||
await expect(transcript.getByRole('article')).toHaveCount(1);
|
||||
await expectComposerBounds(page);
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('approval details and decisions remain reachable without covering the composer', async ({ page }) => {
|
||||
const errors = await openChat(page, 'approval', 768, 480);
|
||||
const approval = page.getByRole('heading', { name: '需要你的确认', exact: true });
|
||||
|
||||
@@ -91,7 +91,7 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
if (body.operation === 'submit' || body.operation === 'preview') return result({ request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', version: '1', status: 'dispatched' });
|
||||
if (body.operation === 'history') return result({ thread_id: 'thread-1', next_offset: null,
|
||||
queued_requests: [], schedule_proposals: [{ id: 'proposal', name: '每天整理选题', prompt: '每天给我三个适合个人创作的选题', cron_expression: '30 8 * * *', timezone: 'Asia/Shanghai' }],
|
||||
messages: [{ id: 1, role: 'assistant', content: '## 创作建议\n先从一个真实的观察开始。\n\n' + '记录今天的一次观察,把具体场景、你的感受和想分享的发现写下来。\n\n'.repeat(22) }],
|
||||
messages: [{ id: 1, role: 'user', content: '先给我一些创作建议' }, { id: 2, role: 'assistant', content: '## 创作建议\n先从一个真实的观察开始。\n\n' + '记录今天的一次观察,把具体场景、你的感受和想分享的发现写下来。\n\n'.repeat(22) }],
|
||||
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: [] });
|
||||
@@ -266,12 +266,17 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
expect(await messageOperations()).toEqual([]);
|
||||
await expect(formalChat.getByRole('heading', { name: 'Hi,今天想聊些什么?', exact: true })).toBeVisible();
|
||||
await expect(formalChat.locator('.agent-chat-welcome').getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url);
|
||||
const conversationSources = page.getByRole('navigation', { name: '对话来源', exact: true });
|
||||
const sourceBounds = await conversationSources.getByRole('button', { name: '客户端对话', exact: true }).boundingBox();
|
||||
const adjustBounds = await conversationSources.getByRole('button', { name: '调整智能体', exact: true }).boundingBox();
|
||||
expect(sourceBounds).not.toBeNull();
|
||||
expect(adjustBounds).not.toBeNull();
|
||||
expect(Math.abs(sourceBounds!.y + sourceBounds!.height / 2 - adjustBounds!.y - adjustBounds!.height / 2)).toBeLessThanOrEqual(1);
|
||||
const expectCleanChat = async () => {
|
||||
await expect(page.getByRole('navigation', { name: '对话来源', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: '名称', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^(已保存|保存草稿|已应用|应用最新修改|调整智能体|修改头像|修改名称|查看历史对话|新对话|客户端对话|微信对话)$/ })).toHaveCount(0);
|
||||
await expect(page.locator('.agent-chat-toolbar')).toHaveCount(0);
|
||||
await expect(page.getByText('和助手聊一聊', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/由创建者支付词元点数/)).toHaveCount(0);
|
||||
};
|
||||
await expectCleanChat();
|
||||
await expect(editor.locator('header').getByRole('heading', { name: '我的写作搭档', exact: true })).toBeVisible();
|
||||
await expect(editor.locator('header').getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url);
|
||||
const example = formalChat.locator('.agent-chat-example').first();
|
||||
const exampleText = await example.innerText();
|
||||
await example.click();
|
||||
@@ -283,6 +288,14 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
await formalChat.getByRole('button', { name: '发送', exact: true }).click();
|
||||
await expect(formalChat.getByRole('heading', { name: '创作建议' })).toBeAttached();
|
||||
expect(await messageOperations()).toEqual(['submit']);
|
||||
const userBubble = formalChat.locator('.agent-chat-message[data-role="user"]');
|
||||
await expect(userBubble).toHaveText(/先给我一些创作建议/);
|
||||
const bubbleStyle = await userBubble.evaluate(element => {
|
||||
const style = getComputedStyle(element);
|
||||
return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) };
|
||||
});
|
||||
expect(bubbleStyle.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(bubbleStyle.radius).toBeGreaterThan(0);
|
||||
await expect(formalChat.locator('.agent-chat-message[data-role="assistant"]').getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url);
|
||||
await expect(formalChat.getByLabel('消息', { exact: true })).toBeInViewport();
|
||||
const transcriptBounds = await formalChat.getByTestId('cloud-chat-messages').boundingBox();
|
||||
@@ -294,7 +307,8 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setSize(980, 680); win.webContents.setZoomFactor(1.5); });
|
||||
await expect(formalChat.getByTestId('cloud-chat-composer')).toBeInViewport({ ratio: 1 });
|
||||
await expect(formalChat.getByRole('button', { name: '发送', exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expect(conversationSources.getByRole('button', { name: '调整智能体', exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expect(page.getByRole('button', { name: '返回智能体列表', exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expectCleanChat();
|
||||
await expect.poll(() => editor.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expect.poll(() => formalChat.getByTestId('cloud-chat-messages').evaluate(el => el.clientHeight)).toBeGreaterThanOrEqual(80);
|
||||
// Chromium screenshots can crop a zoomed Electron surface. Capture the
|
||||
@@ -305,7 +319,8 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
});
|
||||
await writeFile(testInfo.outputPath('cloud-agent-chat-compact-window.png'), Buffer.from(compactWindowImage, 'base64'));
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.webContents.setZoomFactor(1); win.setSize(1366, 768); });
|
||||
await page.getByRole('button', { name: '调整智能体', exact: true }).click();
|
||||
await page.getByRole('button', { name: '返回智能体列表', exact: true }).click();
|
||||
await page.getByRole('article', { name: '我的写作搭档', exact: true }).getByRole('button', { name: '调整', exact: true }).click();
|
||||
await expect(prompt).toHaveValue(initialPrompt);
|
||||
await page.getByLabel('名称', { exact: true }).fill('我的新版写作搭档');
|
||||
await expect(page.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled();
|
||||
@@ -316,6 +331,7 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
await editor.getByRole('button', { name: '我的智能体', exact: true }).click();
|
||||
await page.getByRole('article', { name: '我的新版写作搭档', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click();
|
||||
await expect(formalChat).toBeVisible();
|
||||
await expectCleanChat();
|
||||
await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0);
|
||||
expect(await messageOperations()).toEqual(['submit']);
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-separate-chat.png') });
|
||||
|
||||
Reference in new issue
Block a user