Merge latest main and preserve teacher upgrade safeguards
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
鲨鱼辣椒
2026-09-28 18:42:04 +08:00
28 changed files with 1435 additions and 93 deletions

View File

@@ -1,7 +1,7 @@
// Deterministic responses for layout/interaction tests only; never imported by the app.
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
const definition: TeacherDefinition = { schema_version: 1, config_id: 'coding-teacher', teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
export const fixtureAgent = { teacher_id: definition.config_id!, version: 1, is_default: true, definition };
import { parseTeacherReply } from '../../../shared/teacher-reply';
const definition: TeacherDefinition = { config_id: 'coding-teacher', schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
const samples: Record<string, unknown> = {
ideas: { kind: 'ideas', title: '我的小狗世界', items: [{ id: 'dog', text: '养一只自己的小狗', state: 'kept' }, { id: 'breed', parentId: 'dog', text: '是一只柯基', state: 'kept' }, { id: 'garden', text: '有一片可以种东西的花园', state: 'kept' }, { id: 'food', parentId: 'garden', text: '种出来的东西可以喂小狗', state: 'suggested' }] },
structure: { kind: 'structure', title: '小狗和花园怎么连起来?', nodes: [{ id: 'world', label: '我的小狗世界' }, { id: 'dog', label: '照顾小狗', parentId: 'world' }, { id: 'breed', label: '一只柯基', parentId: 'dog', relation: '它的样子' }, { id: 'garden', label: '种植花园', parentId: 'world' }, { id: 'food', label: '收获食物,喂给小狗', parentId: 'garden', relation: '收获后' }] },
@@ -9,8 +9,22 @@ const samples: Record<string, unknown> = {
comparison: { kind: 'comparison', title: '哪一种更像你想要的小狗?', columns: [{ id: 'click', label: '点了才回应' }, { id: 'active', label: '会主动找我' }], rows: [{ id: 'home', label: '刚回到家的时候', cells: [{ columnId: 'click', text: '等我点一下,才跳起来。' }, { columnId: 'active', text: '看见我就跑过来,还带着玩具。' }] }, { id: 'busy', label: '我忙着种东西的时候', cells: [{ columnId: 'click', text: '安静等着,不打扰我。' }, { columnId: 'active', text: '有时来蹭一蹭,可能也会打断我。' }] }] },
};
const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas';
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, conversation: { agentId: fixtureAgent.teacher_id, segmentTurns: 1 }, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], projectId: 'fixture', sourceConversationId: 'source', teacherVersion: 1, createdAt: `2026-09-28T10:00:${String(i).padStart(2, '0')}Z`, sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) };
export const fixtureAgent = { teacher_id: definition.teacher_id, version: 1, is_default: true, definition };
const continuous = new URLSearchParams(location.search).get('chat') !== 'legacy';
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], projectId: 'fixture', sourceConversationId: 'source', teacherVersion: 1, createdAt: `2026-09-28T10:00:${String(i).padStart(2, '0')}Z`, sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) };
if (continuous) current.conversation = { agentId: definition.teacher_id, segmentTurns: 1, discussions: {} };
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
if (scenario === 'recovered' || scenario === 'incomplete') {
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。'
+ (scenario === 'recovered' ? '","quickReplies":[],"tool":{"kind":"structure","nodes":[]}}' : '');
const parsed = parseTeacherReply(raw);
current.requests = [{
...current.requests[0], projectId: 'fixture', text: '现在项目是什么阶段?', sourceConversationId: 'original-source',
references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
response: parsed.reply, replyIncomplete: parsed.incomplete, replyParseError: parsed.parseError,
suggestedQuestions: parsed.quickReplies, unparsedResponse: raw,
}];
}
if (scenario === 'retry') {
const status = new URLSearchParams(location.search).get('status') === 'cancelled' ? 'cancelled' : 'failed';
current.requests = [{
@@ -31,20 +45,24 @@ const sentInputs: TeacherSend[] = [];
Object.defineProperty(window, '__teacherReplyInputs', { value: sentInputs });
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
export const teacherApi = {
catalog: async () => ({ items: [fixtureAgent] }),
config: async () => ({ enabled: true, definition }),
conversation: async () => ({ topic: structuredClone(current), before: null }),
seenConversation: async (_agent: string, requestId: string) => {
current.conversation!.seenThrough = requestId;
seenConversation: async (_agentId: string, requestId: string) => {
if (current.conversation) current.conversation.seenThrough = requestId;
current.revision++;
return structuredClone(current);
},
sendConversation: async (_agentId: string, input: TeacherSend): Promise<TeacherTopic> => teacherApi.send('/fixture', current.id, input),
catalog: async () => ({ items: [{ teacher_id: definition.teacher_id, version: 1, is_default: true, definition }] }),
config: async () => ({ enabled: true, definition }),
legacyHistory: async () => ({ items: [], lastSelectedTopicId: null }),
list: async () => ({ items: [{ id: current.id, title: '我的小狗世界' }], lastSelectedTopicId: current.id }),
read: async () => structuredClone(current),
create: async () => structuredClone(current),
events: async () => stream,
sendConversation: async (_agent: string, input: TeacherSend) => {
send: async (_base: string, _id: string, input: TeacherSend) => {
if ('presentation' in input || 'discussion' in input) throw new Error('Retired component fields must not be sent');
sentInputs.push(structuredClone(input));
current.requests.push({ id: input.requestId, text: input.text, intent: input.intent, references: [], projectId: input.projectId, sourceConversationId: input.sourceConversationId, teacherVersion: 1, createdAt: `2026-09-28T10:01:${String(sentInputs.length).padStart(2, '0')}Z`, status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', presentation: 'reply-v1' });
current.requests.push({ id: input.requestId, text: input.text, intent: input.intent, references: input.references ?? [], projectId: input.projectId, sourceConversationId: input.sourceConversationId, teacherVersion: 1, createdAt: `2026-09-28T10:01:${String(sentInputs.length).padStart(2, '0')}Z`, status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', presentation: 'reply-v1' });
snapshot();
timer = setTimeout(() => {
const request = current.requests.at(-1)!;

View File

@@ -4,11 +4,13 @@ import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.css';
import 'katex/dist/katex.min.css';
const chat = new URLSearchParams(location.search).get('chat');
const continuous = chat !== 'legacy';
const width = Number(new URLSearchParams(location.search).get('width')) || 508;
createRoot(document.getElementById('root')!).render(<div className="classroom-workspace flex h-screen flex-col bg-white">
<header className="flex h-10 shrink-0 items-center border-b px-5 text-xs">智能体回复交互测试 · 真实页面组件 / 模拟回复</header>
<div className="flex min-h-0 flex-1 justify-end bg-[#f5f5f1]">
<main className="min-w-0 flex-1 p-8"><h1 className="text-lg">我的小狗世界</h1><p className="mt-4 text-sm text-muted-foreground">作品区域</p></main>
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId="source" selectedAgent={fixtureAgent} /></div>
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId={chat === 'continuous' ? 'original-source' : 'source'} selectedAgent={continuous ? fixtureAgent : undefined} /></div>
</div>
</div>);

View File

@@ -21,7 +21,15 @@ type HostConnection = {
token: string;
};
type ConsultationReply = {
response: string;
suggestedQuestions: string[];
replyParseError?: string;
unparsedResponse?: string;
};
type TeacherCatalogFixture = {
nextTeacherReplyIncomplete?: boolean;
teacherCatalog: TeacherCatalog;
teacherCatalogFailure: boolean;
holdTeacherCatalog: boolean;
@@ -96,9 +104,10 @@ async function installCodingFirstChatHost(
managedCapabilities = false,
removedModel = false,
audioPreview?: { executionId: string; path: string; dataUrl: string },
consultationReply?: ConsultationReply,
): Promise<void> {
await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload;
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview, consultationReply } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = TeacherCatalogFixture & {
captured: CapturedRequest[];
@@ -574,6 +583,13 @@ async function installCodingFirstChatHost(
const suggestions = body!.intent === 'suggestions';
const response = suggestions ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
const last = (consultationTopics[agentId].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(last, consultationReply);
if (state.nextTeacherReplyIncomplete) {
state.nextTeacherReplyIncomplete = false;
Object.assign(last, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
}
return respond(consultationTopics[agentId],202);
}
}
@@ -592,6 +608,12 @@ async function installCodingFirstChatHost(
const suggestionRequest = body!.intent === 'suggestions';
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
if (state.nextTeacherReplyIncomplete) {
state.nextTeacherReplyIncomplete = false;
const reply = (consultationTopics[consultationPath].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(reply, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
sourceConversationId: body!.sourceConversationId, unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
}
return respond(consultationTopics[consultationPath],202);
}
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
@@ -836,7 +858,7 @@ async function installCodingFirstChatHost(
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
}, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview });
}, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview, consultationReply });
}
test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => {
@@ -1914,6 +1936,101 @@ test('project consultations preserve student drafts and switch between work and
});
test('incomplete consultation replies retry only on click and preserve both drafts', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(app);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(app, connection, true);
await settleSnapshot(app);
await disableCodingEventSource(page);
await app.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.nextTeacherReplyIncomplete = true;
});
try {
await page.reload();
page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const mainDraft = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await mainDraft.fill('主对话草稿');
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
await draft.fill('现在项目是什么阶段?');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
await expect(teacher.getByRole('status')).toHaveText('这次回复未能完整显示');
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
await expect(teacher.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
const sends = async () => (await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
expect(await sends()).toHaveLength(1);
await draft.fill('咨询里还没写完的草稿');
await teacher.getByRole('button', { name: '重新回答' }).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(draft).toHaveValue('咨询里还没写完的草稿');
await expect(mainDraft).toHaveValue('主对话草稿');
const inputs = await sends();
expect(inputs).toHaveLength(2);
expect(inputs[1].path).toBe(inputs[0].path);
expect(inputs[1].body).toEqual({ ...inputs[0].body, requestId: expect.any(String) });
expect(inputs[1].body!.requestId).not.toBe(inputs[0].body!.requestId);
} finally { await releaseSnapshot(app); }
});
test('project consultations show reply protocol errors without exposing rejected shortcuts', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
const response = '可以先观察玩家在哪里停下来,再聊聊你想改的地方。';
const quickReplies = ['看懂规则|怎样观察玩家?', '想想作用|我想聊聊按钮。', '试试结果|怎样看出变化?', '<button>原始选项</button> **不要渲染**'];
const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。';
const unparsedResponse = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n';
await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, {
response, suggestedQuestions: [], replyParseError, unparsedResponse,
});
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('project-conversations')).toBeVisible();
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('保留操作草稿');
await page.getByRole('button', { name: '与朋友聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
const teacherComposer = teacher.getByRole('textbox', { name: '向智能体提问' });
await teacherComposer.fill('还没说完的困惑');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
const reply = teacher.locator('.consultation-messages > div').filter({ has: page.getByText(response, { exact: true }) });
await expect(reply.getByText(response, { exact: true })).toBeVisible();
await expect(reply.getByRole('status')).toHaveText('回答正文已保留,部分附加内容未能显示。');
await expect(reply.getByRole('status')).toBeVisible();
await expect(teacher.locator('.consultation-follow-up')).toHaveCount(0);
for (const question of quickReplies) {
await expect(teacher.getByRole('button', { name: question, exact: true })).toHaveCount(0);
}
const raw = reply.locator('details').filter({ has: page.getByText('查看收到的原始内容', { exact: true }) });
await expect(raw).not.toHaveAttribute('open');
await expect(raw.locator('pre')).toBeHidden();
await raw.locator('summary').click();
await expect(raw.locator('pre')).toBeVisible();
expect(await raw.locator('pre').textContent()).toBe(unparsedResponse);
await expect(raw.locator('button, strong')).toHaveCount(0);
await expect(teacherComposer).toHaveValue('');
await expect(composer).toHaveValue('保留操作草稿');
const requests = (await readState(electronApp)).captured;
expect(requests.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1);
expect(requests.filter(item => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(0);
} finally {
await releaseSnapshot(electronApp);
}
});
test('manual agent refresh updates the catalog and retries failures without replacing the ongoing chat or drafts', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
@@ -2057,6 +2174,41 @@ test('settled prompt releases the composer without a client receipt and keeps th
} finally { await releaseSnapshot(electronApp); }
});
test('teacher avatar consumes the welcome bubble through close, reopen and reload without a model request', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const teacherButton = page.getByRole('button', { name: '与代码智能体聊天', exact: true });
const invitation = page.getByTestId('teacher-invitation');
const teacher = page.getByTestId('teacher-chat-panel');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
await teacherButton.click();
await expect(teacher).toBeVisible();
await expect(teacher.getByText('一起理解代码', { exact: true })).toBeVisible();
await expect(invitation).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('teacher-avatar-consumed-welcome.png') });
await teacherButton.click();
await expect(teacher).toHaveCount(0);
await expect(invitation).toHaveCount(0);
await teacherButton.click();
await expect(teacher).toBeVisible();
await expect(invitation).toHaveCount(0);
await teacherButton.click();
await expect(teacher).toHaveCount(0);
await page.reload();
await expect(teacherButton).toBeVisible();
await expect(invitation).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
} finally { await releaseSnapshot(electronApp); }
});
test('teacher stays in the top-right header without legacy check-ins or taking over the student draft', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);

View File

@@ -47,6 +47,40 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation',
});
for (const width of [319, 508]) {
for (const chat of ['legacy', 'continuous']) test(`quoted ${chat} replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
await page.goto(`${url}?kind=recovered&width=${width}&chat=${chat}`);
const panel = page.getByTestId('teacher-chat-panel');
const body = page.getByTestId('teacher-reply');
await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了');
await expect(body).toContainText('现在的阶段是「能玩,正在调手感」。');
await expect(page.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
await expect(page.getByRole('button', { name: '重新回答' })).toHaveCount(0);
expect(await sent()).toEqual([]);
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) });
await page.goto(`${url}?kind=incomplete&width=${width}&chat=${chat}`);
await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示');
await expect(page.getByTestId('teacher-reply')).toHaveCount(0);
expect(await sent()).toEqual([]);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('我还在写的新想法');
const retry = page.getByRole('button', { name: '重新回答' });
const bounds = (await retry.boundingBox())!;
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`incomplete-${width}.png`) });
await retry.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('我还在写的新想法');
expect(await sent()).toEqual([{
requestId: expect.any(String), text: '现在项目是什么阶段?', ...(chat === 'continuous' ? { projectId: 'fixture' } : {}),
sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
}]);
});
test(`empty-composer hint stays readable and keeps the send position stable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
await page.goto(`${url}?kind=ordinary&width=${width}`);