feat(chat): add manual refresh for delivered agents
This commit is contained in:
@@ -14,7 +14,9 @@ export function Layout() {
|
||||
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,
|
||||
@@ -27,6 +29,16 @@ export function Layout() {
|
||||
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">
|
||||
|
||||
@@ -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,8 +466,8 @@ 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: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
|
||||
];
|
||||
@@ -533,11 +545,20 @@ 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 consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1];
|
||||
if (consultationPath) {
|
||||
@@ -546,7 +567,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);
|
||||
}
|
||||
@@ -1864,6 +1885,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 open topic 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);
|
||||
// Existing discussions keep their original definition and all unsent text.
|
||||
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);
|
||||
|
||||
@@ -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') });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user