Merge remote main and reconcile agent refresh with ongoing chats
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 15:02:09 +08:00
24 changed files with 1122 additions and 165 deletions

View File

@@ -9,35 +9,52 @@ import '../../../src/pages/Chat/classroom-workspace.css';
// window is launched, and no user project, provider, or live Host API is used.
export function Layout() {
const [bubble, setBubble] = useState<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(true);
const [introduction, setIntroduction] = useState<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(!new URLSearchParams(location.search).has('quiet'));
const [open, setOpen] = useState(false);
const [width, setWidth] = useState(508);
const [long, setLong] = useState(true);
const count = Number(new URLSearchParams(location.search).get('agents') ?? 0);
const [count, setCount] = useState(Number(new URLSearchParams(location.search).get('agents') ?? 0));
const [refreshing, setRefreshing] = useState(false);
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [selected, setSelected] = useState(0);
const agents = Array.from({ length: count }, (_, index) => ({
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
definition: { config_id: `agent-${index}`, name: `智能体${index + 1}`, avatar_id: 'avatar-06',
description: `我是智能体${index + 1},陪你把脑洞变成小作品。一起发现有趣的问题,也一起想清楚下一步怎么做。`,
icon_url: new URLSearchParams(location.search).has('icons') ? `https://yuxi.example.test/api/storage/public/images/agent-${index}.png` : undefined },
}));
const companion = {
agents, selectedAgent: agents[selected],
agents, unreadAgentIds: [], selectedAgent: agents[selected],
definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' },
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
welcome: null, enabled: true, loading: false, checking: false, error: null,
refreshing, refreshStatus,
refresh: async () => {
setRefreshing(true);
setRefreshStatus('idle');
await new Promise((resolve) => window.setTimeout(resolve, 100));
setCount((current) => current + 1);
setRefreshStatus('success');
setRefreshing(false);
},
reloadCatalog: async () => {},
dismiss: () => setVisible(false),
} as ComponentProps<typeof TeacherCompanion>['companion'];
return <div className="classroom-workspace flex h-screen flex-col bg-white">
<header className="relative z-50 flex h-10 shrink-0 items-center justify-end border-b px-3" data-testid="coding-conversation-header">
<span className="mr-auto min-w-0 truncate text-xs">布局测试项目 › hello</span>
<TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(!open); }} bubbleRef={setBubble} />
<TeacherCompanion companion={companion} open={open}
onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); }}
onToggle={(id) => { if (!id || id === agents[selected]?.teacher_id) setOpen(!open); else { setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); } }}
bubbleRef={setBubble} introductionRef={setIntroduction} />
<span data-testid="window-controls" className="w-[132px] shrink-0 text-center text-xs">— □ ×</span>
</header>
<div className="relative flex min-h-0 flex-1" data-testid="coding-chat-panel" data-consultation-open={open}>
<main className="flex min-w-0 flex-1 flex-col">
<div className="classroom-tabs" role="tablist"><button role="tab">操作对话</button><button role="tab" aria-selected="true">作品</button></div>
<div className="classroom-work-view flex min-h-0 flex-1 flex-col p-5" data-testid="work-view">
<AgentBrowserPanel projectId="layout-fixture" embedded open teacherBubble={bubble} onOpenChange={() => {}} />
<AgentBrowserPanel projectId="layout-fixture" embedded open teacherBubble={bubble} teacherIntroduction={introduction} onOpenChange={() => {}} />
</div>
</main>
{open && <aside id="coding-consultation-dock" className="classroom-consultation-dock consultation-pane shrink-0 border-l p-5" style={{ width }}>

View File

@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser';
import type { TeacherCatalog, TeacherDefinition } from '../../shared/coding-teacher';
import { expect, getStableWindow, test } from './fixtures/electron';
type CapturedRequest = {
@@ -20,6 +21,13 @@ type HostConnection = {
token: string;
};
type TeacherCatalogFixture = {
teacherCatalog: TeacherCatalog;
teacherCatalogFailure: boolean;
holdTeacherCatalog: boolean;
releaseTeacherCatalog: (() => void) | null;
};
async function disableCodingEventSource(page: Page): Promise<void> {
await page.addInitScript(() => {
const sources = new Set<LocalEventSource>();
@@ -92,7 +100,7 @@ async function installCodingFirstChatHost(
await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
type MainState = TeacherCatalogFixture & {
captured: CapturedRequest[];
conversationCreated: boolean;
interactionAnswered: boolean;
@@ -116,6 +124,10 @@ async function installCodingFirstChatHost(
releaseSnapshot: null,
snapshotPending: false,
snapshotSettled: false,
teacherCatalog: { items: [] },
teacherCatalogFailure: false,
holdTeacherCatalog: false,
releaseTeacherCatalog: null,
};
mainGlobal.__makelorePiFirstChatE2E = state;
const now = '2026-08-24T00:00:00.000Z';
@@ -454,10 +466,10 @@ async function installCodingFirstChatHost(
...overrides,
});
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teachers = [
const teacherDefinition: TeacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
state.teacherCatalog.items = [
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
];
const consultationTopics: Record<string, Record<string, unknown>> = {};
ipcMain.removeHandler('hostapi:fetch');
@@ -533,16 +545,25 @@ async function installCodingFirstChatHost(
}
if (path.endsWith('/teacher-check-in') && method === 'POST') {
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:teacherDefinition,version:1,createdAt:now,updatedAt:now,requests:[]};
const published = state.teacherCatalog.items.find((item) => item.is_default) ?? state.teacherCatalog.items[0];
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:published?.definition ?? teacherDefinition,version:published?.version ?? 1,createdAt:now,updatedAt:now,requests:[]};
consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
return respond({topic:consultationTopics.agent});
}
if (path === '/api/coding/teacher/teachers') return respond({ items: teachers });
if (path === '/api/coding/teacher/teachers') {
if (state.holdTeacherCatalog) {
await new Promise<void>((resolveCatalog) => { state.releaseTeacherCatalog = resolveCatalog; });
state.releaseTeacherCatalog = null;
state.holdTeacherCatalog = false;
}
if (state.teacherCatalogFailure) return respond({ error: 'Catalog unavailable' }, 503);
return respond(state.teacherCatalog);
}
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
const agentChat = path.match(/^\/api\/coding\/agent-conversations\/([^/?]+)(?:\/(messages|seen))?(?:\?.*)?$/);
if (agentChat) {
const agentId = agentChat[1], action = agentChat[2];
const selected = teachers.find(item => item.teacher_id === agentId)!;
const selected = state.teacherCatalog.items.find(item => item.teacher_id === agentId)!;
const current = consultationTopics[agentId] ?? {schemaVersion:1,revision:0,id:'chat-' + agentId,accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[],conversation:{agentId,segmentTurns:0,discussions:{}}};
if (!action && method === 'GET') return respond({topic: consultationTopics[agentId] ?? null, before:null});
if (action === 'seen') {
@@ -563,7 +584,7 @@ async function installCodingFirstChatHost(
const currentTopic = consultationTopics[consultationPath];
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题',teacherId:(currentTopic.definition as {config_id?:string}).config_id}]:[],lastSelectedTopicId:currentTopic?.id??null});
if (path.endsWith(topicBase) && method === 'POST') {
const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0];
const selected = state.teacherCatalog.items.find(item => item.version === body?.teacherVersion) ?? state.teacherCatalog.items[0];
consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
return respond(consultationTopics[consultationPath],201);
}
@@ -1803,8 +1824,19 @@ test('project consultations preserve student drafts and switch between work and
await expect(composer).toBeHidden();
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toHaveValue('保留我的草稿');
await page.getByRole('button',{name:'与代码智能体聊天',exact:true}).hover();
await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
await page.getByRole('button',{name:'与朋友聊天',exact:true}).hover();
await expect(page.getByTestId('teacher-introduction')).toHaveCount(1);
await expect(page.getByTestId('teacher-introduction')).toContainText('陪你听听玩家的想法,让作品更有趣。');
await expect(page.getByTestId('teacher-chat-panel')).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
const teacher=page.getByTestId('teacher-chat-panel');
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
await expect(teacher).toHaveAccessibleName('朋友咨询');
await expect(teacher.locator('header')).toHaveCount(0);
await expect(teacher.getByText('陪你听听玩家的想法,让作品更有趣。')).toHaveCount(0);
await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true');
await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible();
await expect(composer).toBeHidden();
@@ -1836,14 +1868,21 @@ test('project consultations preserve student drafts and switch between work and
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toBeVisible();
await teacher.getByRole('button',{name:'关闭智能体'}).click();
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(teacher).toHaveCount(0);
await expect(page.getByRole('button',{name:'与朋友聊天',exact:true})).toBeFocused();
await page.getByRole('button', { name: '与代码智能体聊天' }).click();
await expect(teacher.getByRole('heading', { name: '代码智能体' })).toBeVisible();
await expect(teacher).toHaveAccessibleName('代码智能体咨询');
await expect(teacher.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('');
await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('另一个智能体的草稿');
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
// Moving inside the clicked entry must not schedule its introduction again.
const friendEntry = (await page.getByRole('button', { name: '与朋友聊天', exact: true }).boundingBox())!;
await page.mouse.move(friendEntry.x + friendEntry.width / 2 + 2, friendEntry.y + friendEntry.height / 2);
await page.waitForTimeout(300);
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
const requests=(await readState(electronApp)).captured;
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST');
@@ -1852,13 +1891,13 @@ test('project consultations preserve student drafts and switch between work and
expect(consultationRequests.every(item => item.path === '/api/coding/agent-conversations/teacher-algorithm/messages')).toBe(true);
await expect(teacher.getByRole('button', { name: '智能体新话题' })).toHaveCount(0);
await expect(teacher.getByRole('combobox')).toHaveCount(0);
await expect(teacher.getByRole('heading', { name: '朋友', exact: true })).toBeVisible();
await expect(teacher).toHaveAccessibleName('朋友咨询');
expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'帮我看看'});
expect(consultationRequests[1].body).toMatchObject({text:'怎样观察别人玩游戏?'});
expect(consultationRequests[1].body?.intent).toBeUndefined();
expect(consultationRequests[2].body).toMatchObject({intent:'guided-help',text:'我也说不清,你带我看看'});
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
await teacher.getByRole('button',{name:'关闭智能体'}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
@@ -1866,6 +1905,107 @@ test('project consultations preserve student drafts and switch between work and
});
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);
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'; });
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');
await teacher.getByRole('button', { name: '解释当前代码', exact: true }).click();
await teacher.getByRole('button', { name: '提问', exact: true }).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
await draft.fill('智能体聊天里还没说完的话');
const before = (await readState(electronApp)).captured.length;
await electronApp.evaluate(() => {
const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture }).__makelorePiFirstChatE2E;
const original = state.teacherCatalog.items[0];
state.teacherCatalog.items = [
{ ...original, version: 2, definition: { ...original.definition, name: '新版代码智能体', description: '更新后的编程伙伴介绍。' } },
state.teacherCatalog.items[1],
{ teacher_id: 'teacher-science', version: 15, is_default: false,
definition: { ...original.definition, config_id: 'teacher-science', name: '科学智能体', welcome_message: '一起探索科学', description: '刚刚下发的新智能体。' } },
];
state.holdTeacherCatalog = true;
});
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
await refresh.click();
await expect(refresh).toBeDisabled();
await expect(page.getByRole('status').filter({ hasText: '正在刷新' })).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect.poll(() => electronApp.evaluate(() => Boolean(
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.releaseTeacherCatalog,
))).toBe(true);
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.releaseTeacherCatalog?.();
});
await expect(refresh).toBeEnabled();
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '与新版代码智能体聊天', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '与代码智能体聊天', exact: true })).toHaveCount(0);
// The contact uses its current name while history and all unsent text stay in the same chat.
await expect(teacher).toHaveAccessibleName('新版代码智能体咨询');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(composer).toHaveValue('主会话还没发出的想法');
const refreshedRequests = (await readState(electronApp)).captured.slice(before);
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/teachers')).toHaveLength(1);
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/config')).toHaveLength(1);
expect(refreshedRequests.filter((request) => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
expect(refreshedRequests.filter((request) => /\/agent-topics(?:\/|$)/.test(request.path))).toHaveLength(0);
await page.screenshot({ path: test.info().outputPath('manual-agent-refresh.png') });
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.teacherCatalogFailure = true;
});
await refresh.click();
await expect(page.getByRole('status').filter({ hasText: '刷新失败,请重试' })).toBeVisible();
await expect(refresh).toBeEnabled();
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.teacherCatalogFailure = false;
});
await refresh.click();
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await page.getByRole('button', { name: '与科学智能体聊天', exact: true }).click();
await expect(teacher).toHaveAccessibleName('科学智能体咨询');
await expect(teacher.getByText('一起探索科学')).toBeVisible();
await page.getByRole('button', { name: '与新版代码智能体聊天', exact: true }).click();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(composer).toHaveValue('主会话还没发出的想法');
const finalRequests = (await readState(electronApp)).captured.slice(before);
expect(finalRequests.filter((request) => /\/(messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
} finally {
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: TeacherCatalogFixture })
.__makelorePiFirstChatE2E?.releaseTeacherCatalog?.();
}).catch(() => undefined);
await releaseSnapshot(electronApp);
}
});
test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
@@ -1908,7 +2048,7 @@ test('settled prompt releases the composer without a client receipt and keeps th
} finally { await releaseSnapshot(electronApp); }
});
test('teacher stays in the top-right header and proactively checks in without taking over the student draft', async ({ launchElectronApp }) => {
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);
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 }));
@@ -1920,9 +2060,9 @@ test('teacher stays in the top-right header and proactively checks in without ta
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const header = page.getByTestId('coding-conversation-header');
const companion = header.getByTestId('teacher-companion');
const teacherButton = companion.getByRole('button', { name: '智能体', exact: true });
const header = page.getByTestId('coding-conversation-header-titlebar');
const companion = page.getByTestId('teacher-companion');
const teacherButton = companion.getByRole('button', { name: '与代码智能体聊天', exact: true });
await expect(teacherButton).toHaveText('代码智能体');
await expect(teacherButton).toBeVisible();
await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/);
@@ -1946,11 +2086,28 @@ test('teacher stays in the top-right header and proactively checks in without ta
await composer.fill('我还在写自己的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(teacherButton).toBeVisible();
await teacherButton.hover();
await page.clock.runFor(250);
const introduction = page.getByTestId('teacher-introduction');
await expect(introduction).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
await expect(introduction).toBeInViewport({ ratio: 1 });
const [introductionBounds, introductionViewportBounds] = await Promise.all([
introduction.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
]);
// Native WebContentsView must leave room for the portaled introduction too.
expect(introductionBounds).not.toBeNull();
expect(introductionViewportBounds).not.toBeNull();
expect(introductionViewportBounds!.y).toBeGreaterThanOrEqual(introductionBounds!.y + introductionBounds!.height);
await page.keyboard.press('Escape');
await expect(introduction).toHaveCount(0);
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await composer.focus();
await page.clock.fastForward(300_000);
await expect(invitation).toContainText('排行榜');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'check-in');
await expect(invitation).toContainText('一起理解代码');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await expect(invitation).toBeInViewport({ ratio: 1 });
const presence = companion.getByTestId('teacher-presence');
await expect(presence.getByRole('button', { name: '打开这条智能体消息' })).toBeVisible();
@@ -1967,7 +2124,7 @@ test('teacher stays in the top-right header and proactively checks in without ta
await expect(composer).toBeFocused();
await expect(composer).toHaveValue('我还在写自己的想法');
await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true');
await page.screenshot({ path: test.info().outputPath('teacher-proactive-invitation.png') });
await page.screenshot({ path: test.info().outputPath('teacher-published-welcome.png') });
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(invitation).toBeInViewport({ ratio: 1 });
const [workInvitationBounds, browserViewportBounds] = await Promise.all([
@@ -1984,7 +2141,7 @@ test('teacher stays in the top-right header and proactively checks in without ta
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect(composer).toHaveValue('我还在写自己的想法');
await page.clock.fastForward(300_000);
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(1);
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await invitation.getByRole('button', { name: '和智能体聊聊' }).click();
await expect(invitation).toHaveCount(0);
await expect(presence).toHaveCount(0);
@@ -1992,29 +2149,28 @@ test('teacher stays in the top-right header and proactively checks in without ta
await expect(page.getByRole('tab', { name: '作品', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: '作品', exact: true })).toBeVisible();
const teacher = page.getByTestId('teacher-chat-panel');
await expect(teacher.getByText('你刚才想加排行榜,我们一起想想比什么更有意思?')).toBeVisible();
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await expect(teacher.getByRole('button', { name: '智能体新话题' })).toHaveCount(0);
await expect(teacher.getByRole('combobox')).toHaveCount(0);
await expect(teacher.locator('.consultation-user')).toHaveCount(0);
const teacherInput = teacher.getByRole('textbox', { name: '向智能体提问' });
await expect(teacherInput).toBeFocused();
await teacherInput.fill('我想比谁更了解自己的宠物');
await teacher.getByRole('button', { name: '关闭智能体' }).click();
await expect(page.getByRole('button', { name: '智能体', exact: true })).toBeFocused();
await page.getByRole('button', { name: '智能体', exact: true }).click();
await teacherInput.press('Escape');
await expect(teacher).toHaveCount(0);
await expect(teacherButton).toBeFocused();
await teacherButton.click();
await expect(teacherInput).toHaveValue('我想比谁更了解自己的宠物');
await teacherInput.fill('');
// Let the cleared draft propagate through the parent before advancing the check-in timer.
// Delivered-agent chats must not restart the legacy paid check-in timer.
await page.clock.runFor(32);
await page.clock.fastForward(300_000);
await page.clock.runFor(15_000);
await expect(invitation).toBeVisible();
await expect(teacher).toBeVisible();
await expect(invitation.getByRole('button', { name: '看看智能体说的' })).toBeVisible();
// The message sits over the separate teacher pane and must not push Work down.
await expect.poll(async () => (await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBe(20);
await page.screenshot({ path: test.info().outputPath('teacher-open-proactive-invitation.png') });
await expect(invitation).toBeInViewport({ ratio: 1 });
await invitation.getByRole('button', { name: '等会儿聊' }).click();
await expect(invitation).toHaveCount(0);
await expect(teacher).toBeVisible();
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await expect.poll(async () => (await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBe(20);
await page.screenshot({ path: test.info().outputPath('teacher-open-continuous-chat.png') });
await expect(composer).toHaveValue('我还在写自己的想法');
expect((await readState(electronApp)).captured.filter((item) => /\/(messages|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
} finally { await releaseSnapshot(electronApp); }

View File

@@ -40,7 +40,10 @@ for (const width of [520, 1000]) {
await page.setViewportSize({ width, height: 800 });
await page.goto(baseUrl + '?agents=8');
const tabs = page.getByRole('navigation', { name: '已下发智能体' });
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
await expect(tabs.getByRole('button')).toHaveCount(8);
await expect(refresh).toBeVisible();
await expect(tabs.getByRole('button', { name: '刷新智能体', exact: true })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
const last = tabs.getByRole('button', { name: '与智能体8聊天' });
await last.focus();
@@ -48,13 +51,22 @@ for (const width of [520, 1000]) {
await expect(last).toHaveAttribute('aria-pressed', 'true');
const bounds = await page.evaluate(() => {
const strip = document.querySelector('.consultation-agent-tabs')!;
const refresh = document.querySelector('button[aria-label="刷新智能体"]')!;
const controls = document.querySelector('[data-testid="window-controls"]')!;
return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left,
refreshLeft: refresh.getBoundingClientRect().left, refreshRight: refresh.getBoundingClientRect().right,
scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth };
});
expect(bounds.right).toBeLessThanOrEqual(bounds.controlsLeft);
expect(bounds.right).toBeLessThanOrEqual(bounds.refreshLeft);
expect(bounds.refreshRight).toBeLessThanOrEqual(bounds.controlsLeft);
expect(bounds.documentWidth).toBeLessThanOrEqual(width);
expect(bounds.scroll).toBeGreaterThan(0);
await refresh.click();
await expect(tabs.getByRole('button')).toHaveCount(9);
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await expect(last).toHaveAttribute('aria-pressed', 'true');
await expect(refresh).toBeInViewport();
await expect(refresh).toBeEnabled();
await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') });
});
}
@@ -70,6 +82,76 @@ async function geometry(page: Page) {
});
}
for (const width of [520, 1000]) {
test(`teacher introduction floats outside the scrolling strip and clears the work at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto(`${baseUrl}?agents=8&quiet=1`);
const first = page.getByRole('button', { name: '与智能体1聊天', exact: true });
const card = page.getByTestId('teacher-introduction');
await expect(first).not.toHaveAttribute('title');
await first.hover();
await expect(card).toBeVisible();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
await expect(card).toHaveCount(1);
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体2');
await first.hover();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
await expect.poll(async () => page.evaluate(() => {
const card = document.querySelector('[data-testid="teacher-introduction"]')!.getBoundingClientRect();
const viewport = document.querySelector('[data-testid="agent-browser-viewport"]')!.getBoundingClientRect();
return card.left >= 0 && card.right <= innerWidth && card.bottom <= innerHeight
&& (card.left >= viewport.right || card.right <= viewport.left || viewport.top - card.bottom >= 8);
})).toBe(true);
await card.hover();
await expect(card).toBeVisible();
await page.screenshot({ path: test.info().outputPath('teacher-hover-introduction.png') });
const cardBounds = (await card.boundingBox())!;
await page.screenshot({ path: test.info().outputPath('teacher-introduction-card.png'), clip: {
x: Math.max(0, cardBounds.x - 12), y: cardBounds.y - 12,
width: cardBounds.width + 24, height: cardBounds.height + 30,
} });
await page.mouse.move(0, 780);
await expect(card).toHaveCount(0);
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
const last = page.getByRole('button', { name: '与智能体8聊天', exact: true });
await last.focus();
await expect(card).toBeVisible();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体8');
await last.press('Escape');
await expect(card).toHaveCount(0);
await expect(last).toBeFocused();
await last.hover();
await expect(card).toBeVisible();
await last.click();
await expect(card).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toBeVisible();
const lastBounds = (await last.boundingBox())!;
await page.mouse.move(lastBounds.x + lastBounds.width / 2 + 2, lastBounds.y + lastBounds.height / 2);
await page.waitForTimeout(300);
await expect(card).toHaveCount(0);
await page.mouse.move(0, 780);
await last.hover();
await expect(card).toBeVisible();
await last.click();
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
});
}
test('closing an introduction preserves the unread speech and its native preview clearance', async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 720 });
await page.goto(`${baseUrl}?agents=2`);
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
const original = (await geometry(page)).clearance;
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
await expect(page.getByTestId('teacher-introduction')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
await expect.poll(async () => (await geometry(page)).clearance).toBe(original);
});
test('distributed icons load over HTTP and broken images fall back in the titlebar', async ({ page }) => {
await page.route('https://yuxi.example.test/api/storage/public/images/agent-0.png', route => route.fulfill({
contentType: 'image/svg+xml',