Files
makelore/tests/e2e/cloud-agent-chat-layout.spec.ts
T

526 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
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 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 () => {
const entry = resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx');
server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite-cloud-agent-chat-layout',
resolve: { alias: { '@': resolve('src') } },
esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'isolated-cloud-agent-chat-layout', enforce: 'pre',
resolveId(id) { if (id === '/__cloud-agent-chat-layout-harness.tsx') return entry; },
load(id) {
if (id === entry) return `
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx';
import { CloudAvatar } from '/src/pages/CloudAgents/CloudAvatar.tsx';
import avatar from '/src/assets/agent-avatars/avatar-05.png';
import '/src/styles/globals.css';
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
const name = new URLSearchParams(location.search).has('longName') ? '我的故事搭档'.repeat(16) : '我的故事搭档';
function Harness() {
const [header, setHeader] = useState(null);
return <main className="flex h-screen min-h-0 min-w-0 flex-col">
<header className="agent-workspace-header agent-chat-workspace-header">
<CloudAvatar src={avatar} name={name} className="agent-chat-header-avatar" />
<h2 className="agent-chat-header-name" title={name}>{name}</h2>
<div ref={setHeader} className="agent-chat-header-files" />
</header>
<CloudChat slug="ml-chat-layout-fixture" assistantName="我的故事搭档" assistantAvatarUrl={avatar}
examples={examples} attachmentHeader={header} />
</main>;
}
createRoot(document.getElementById('root')).render(<Harness />);
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url?.split('?')[0] !== '/') return next();
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-chat-layout-harness.tsx"></script></body></html>');
});
},
}],
});
await server.listen();
baseUrl = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
const slug = 'ml-chat-layout-fixture';
const date = '2026-10-12T12:00:00Z';
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
const longWord = 'an-unbroken-story-notebook-'.repeat(24);
const richReply = [
'## 一起把小发现变成故事',
'你可以先选一个最喜欢的发现,再让角色去试一试。',
'[下载故事笔记](sandbox:/故事笔记.md)',
`这个很长的名字也应该完整保留:${longWord}`,
`[查看完整故事线索](https://example.invalid/story/${longWord})`,
['| 今天的线索 | 可以接着想什么 |', '| --- | --- |',
...Array.from({ length: 8 }, (_, index) => `| 第 ${index + 1} 个小发现 | ${longWord.slice(0, 120)} |`),
].join('\n'),
['```javascript', `const completeStoryTitle = "${longWord}";`,
...Array.from({ length: 6 }, (_, index) => `console.log("第 ${index + 1} 个新想法");`),
'```',
].join('\n'),
].join('\n\n');
function makeThread(id: string, title: string, status = 'completed'): CloudThread {
return { thread_id: id, client_thread_id: id, agent_slug: slug, title, mode: 'published',
updated_at: date, run_id: null, status, unread: false };
}
function makeRun(thread: CloudThread, status: string, interrupt?: CloudInterrupt): CloudRun {
return { agent_run_id: 'run-' + thread.thread_id, request_id: 'request-' + thread.thread_id,
thread_id: thread.thread_id, agent_slug: slug, status, output: '', version: '1', interrupt };
}
function makeHistory(thread: CloudThread, dense = false, run: CloudRun | null = null): CloudHistory {
return { thread_id: thread.thread_id, next_offset: null, queued_requests: [], run,
messages: dense ? Array.from({ length: 16 }, (_, index) => ({
id: index + 1, role: index % 2 ? 'assistant' as const : 'user' as const,
content: index === 15 ? richReply : index % 2 ? `第 ${index + 1} 次回答:我们一起想一想,角色还可以试着做什么?` : `第 ${index + 1} 个想法:我想写一只会寻找新发现的小狐狸。`,
request_id: 'fixture-request-' + index, run_id: null, created_at: date,
})) : [{ id: 1, role: 'assistant', content: '这是另一段保留下来的故事对话。',
request_id: 'fixture-request-short', run_id: null, created_at: date }],
};
}
type FixtureCall = { path: string; method: string; input: Record<string, unknown> | null };
type FixtureSeed = { threads: CloudThread[]; histories: Record<string, CloudHistory> };
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' | 'delivery';
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 = 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]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] }
: kind === 'delivery' ? { ...makeHistory(first), messages: [
{ id: 1, role: 'user', content: '[用户提到的文件](sandbox:/user.txt)', request_id: 'delivery-user', run_id: null, created_at: date },
{ id: 2, role: 'assistant', request_id: 'delivery-answer', run_id: null, created_at: date,
content: '[下载完成的小程序](sandbox:/deliverable.py)\n\n准备时用到了 /internal.py、/call.txt、/cache.json 和 /plain.txt。\n\n`[行内示例](sandbox:/inline.py)`\n\n```markdown\n[代码示例](sandbox:/example.py)\n```',
reasoning_content: '[思考过程](sandbox:/thought.txt)',
},
] } : 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: [], 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 });
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => {
if (channel === 'hostapi:token') return 'fixture-token';
if (channel === 'hostapi:base-url') return 'http://fixture.invalid';
if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC: ' + channel);
const path = request?.path ?? '';
const method = request?.method ?? 'GET';
const input = request?.body ? JSON.parse(request.body) as Record<string, unknown> : null;
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/files/save') return result({ saved: true });
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;
const thread = state.threads.find(item => item.thread_id === args?.thread_id) ?? state.threads[0];
const history = thread ? state.histories[thread.thread_id] : null;
switch (operation) {
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: 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: ['故事笔记.md', 'deliverable.py', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']
.map(name => ({ name, is_dir: false, path: '/' + name, directory_path: '/', 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': {
if (!history?.run) throw new Error('Missing approval fixture run');
history.run = { ...history.run, status: 'completed', interrupt: undefined };
return result({ run_id: history.run.agent_run_id, status: 'completed' });
}
default: throw new Error('Unexpected fixture operation: ' + operation);
}
},
} } });
}, { seed });
await page.route('**/*', route => {
const url = new URL(route.request().url());
if (url.hostname === '127.0.0.1') return route.continue();
return route.abort('blockedbyclient');
});
}
async function openChat(page: Page, kind: FixtureKind, width: number, height: number, longName = false) {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.setViewportSize({ width, height });
await installFixture(page, kind);
await page.goto(baseUrl + (longName ? '?longName=1' : ''));
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;
}
async function expectComposerBounds(page: Page) {
const composer = page.getByTestId('cloud-chat-composer');
await expect(composer).toBeInViewport({ ratio: 1 });
const overflow = await page.evaluate(() => ({
document: document.documentElement.scrollWidth - innerWidth,
body: document.body.scrollWidth - innerWidth,
elements: [...document.querySelectorAll('.agent-chat, [data-testid="cloud-chat-messages"], [data-testid="cloud-chat-composer"]')]
.map(element => ({ width: element.scrollWidth - element.clientWidth, name: element.getAttribute('data-testid') ?? element.className })),
composer: (() => {
const bounds = document.querySelector('[data-testid="cloud-chat-composer"]')!.getBoundingClientRect();
return { left: bounds.left, right: bounds.right, bottom: bounds.bottom };
})(),
}));
expect(overflow.document).toBeLessThanOrEqual(1);
expect(overflow.body).toBeLessThanOrEqual(1);
expect(overflow.elements.filter(item => item.width > 1)).toEqual([]);
expect(overflow.composer.left).toBeGreaterThanOrEqual(0);
expect(overflow.composer.right).toBeLessThanOrEqual(page.viewportSize()!.width);
expect(overflow.composer.bottom).toBeLessThanOrEqual(page.viewportSize()!.height);
}
async function operations(page: Page) {
return page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
.filter(call => call.path === '/api/cloud-agents/actions').map(call => call.input));
}
async function screenshot(page: Page, name: string) {
const path = test.info().outputPath(name + '.png');
await page.screenshot({ path, animations: 'disabled' });
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 });
}
test('long Agent identity and attachment entry fit on a short narrow window', async ({ page }) => {
const errors = await openChat(page, 'history', 375, 540, true);
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
await expect(entry).toBeVisible();
const header = page.locator('.agent-chat-workspace-header');
expect((await header.boundingBox())!.height).toBeLessThanOrEqual(104);
await expect(header.locator('.agent-chat-header-name')).toHaveAttribute('title', '我的故事搭档'.repeat(16));
await expect(header.locator('.agent-attachment-entry')).toHaveCount(1);
await expectComposerBounds(page);
await entry.click();
await expect(page.getByRole('dialog', { name: '本次对话的附件' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(entry).toBeFocused();
await expectComposerBounds(page);
await screenshot(page, 'agent-chat-long-name-attachment-header');
expect(errors).toEqual([]);
});
test('the file Sheet counts only uploads and files explicitly delivered in the assistant answer', async ({ page }) => {
const errors = await openChat(page, 'delivery', 375, 640);
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
await expect(entry).toBeVisible();
await entry.click();
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
const generated = panel.getByRole('region', { name: '助手生成的' });
await expect(generated.getByRole('button', { name: /^保存 / })).toHaveCount(1);
await expect(generated.getByRole('button', { name: '保存 deliverable.py', exact: true })).toBeVisible();
for (const name of ['故事笔记.md', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']) {
await expect(generated.getByText(name, { exact: true })).toHaveCount(0);
}
await generated.getByRole('button', { name: '保存 deliverable.py', exact: true }).click();
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/deliverable.py' }]);
await page.keyboard.press('Escape');
await expect(entry).toBeFocused();
await expectComposerBounds(page);
expect((await operations(page)).some(item => ['submit', 'preview', 'resume', 'deleteAttachment'].includes(String(item?.operation)))).toBe(false);
expect(errors).toEqual([]);
});
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);
await expect(page.locator('.agent-chat-welcome')).toBeVisible();
await expect(page.locator('img').first()).toBeVisible();
await expect.poll(() => page.locator('img').evaluateAll(images => images.every(image => {
const element = image as HTMLImageElement;
return element.complete && element.naturalWidth > 0;
}))).toBe(true);
await expectComposerBounds(page);
await screenshot(page, `agent-chat-empty-${width}`);
await page.getByRole('button', { name: examples[0], exact: true }).click();
await expect(page.getByLabel('消息', { exact: true })).toHaveValue(examples[0]);
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
expect((await operations(page)).some(item => ['submit', 'preview', 'createThread', 'resume'].includes(String(item?.operation)))).toBe(false);
await expectComposerBounds(page);
expect(errors).toEqual([]);
});
}
for (const viewport of [{ width: 375, height: 540 }, { width: 768, height: 760 }, { width: 1024, height: 760 }, { width: 1440, height: 900 }]) {
test(`long Markdown history scrolls independently at ${viewport.width}×${viewport.height}`, async ({ page }) => {
const errors = await openChat(page, 'history', viewport.width, viewport.height);
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);
expect(await transcript.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
const before = await composer.boundingBox();
await transcript.evaluate(element => { element.scrollTop = 0; });
await expect.poll(() => transcript.evaluate(element => element.scrollTop)).toBe(0);
await screenshot(page, `agent-chat-message-roles-${viewport.width}`);
const after = await composer.boundingBox();
expect(Math.abs(before!.y - after!.y)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => scrollY)).toBe(0);
await transcript.evaluate(element => { element.scrollTop = element.scrollHeight; });
await screenshot(page, `agent-chat-long-history-${viewport.width}`);
const input = page.getByLabel('消息', { exact: true });
await input.fill(Array.from({ length: 6 }, (_, index) => `第 ${index + 1} 行未发送的故事想法`).join('\n'));
await expectComposerBounds(page);
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('');
const entry = page.getByRole('button', { name: /^附件,共 2 个文件$/ });
await expect(composer.getByRole('button', { name: /附件,共/ })).toHaveCount(0);
await entry.click();
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
await expect(panel).toBeVisible();
const bounds = await panel.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width + 1);
expect(Math.abs(bounds!.x + bounds!.width - viewport.width)).toBeLessThanOrEqual(1);
expect(await panel.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible();
await page.getByRole('button', { name: '保存 故事笔记.md', exact: true }).click();
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/故事笔记.md' }]);
const chosen = page.getByRole('checkbox', { name: '选择附件 我的故事笔记与新发现.txt', exact: true });
await chosen.check();
await screenshot(page, `agent-chat-attachment-panel-${viewport.width}`);
await page.getByRole('button', { name: /返回聊天/ }).click();
await expect(panel).toHaveCount(0);
await expect(composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true })).toBeVisible();
await composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true }).click();
await entry.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.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(entry).toBeFocused();
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();
await page.getByRole('menuitem', { 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 page.getByRole('button', { name: /^附件,共 3 个文件$/ }).click();
await expect(page.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: '关闭附件面板' }).click();
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 });
await expect(stop).toBeInViewport({ ratio: 1 });
await expectComposerBounds(page);
await screenshot(page, 'agent-chat-running-375');
await stop.click();
await expect.poll(async () => (await operations(page)).filter(item => item?.operation === 'cancelRun').length).toBe(1);
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
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 });
await expect(approval).toBeVisible();
const reject = page.getByRole('button', { name: '拒绝', exact: true });
await reject.scrollIntoViewIfNeeded();
await expect(reject).toBeInViewport({ ratio: 1 });
await expect(page.getByRole('button', { name: '允许执行', exact: true })).toBeInViewport({ ratio: 1 });
await expectComposerBounds(page);
await screenshot(page, 'agent-chat-approval-768');
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
await reject.click();
await expect(approval).toHaveCount(0);
const resumes = (await operations(page)).filter(item => item?.operation === 'resume');
expect(resumes).toHaveLength(1);
expect((resumes[0]?.input as Record<string, unknown>).decision).toEqual({ decisions: [{ type: 'reject', message: '用户拒绝执行' }] });
await expectComposerBounds(page);
expect(errors).toEqual([]);
});