Simplify Agent chat with inline attachments and separate thinking

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 21:57:09 +08:00
1 parent 7af43acebb
commit faebfe9e84
18 files changed
+1558 -195

No files matched your search

+23 -33
View File
@@ -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);
+176 -24
View File
@@ -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 });
+25 -9
View File
@@ -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') });