Merge cloud-owned teaching policy and bounded new shortcuts

This commit is contained in:
鲨鱼辣椒
2026-09-28 18:06:30 +08:00
12 changed files with 319 additions and 32 deletions

View File

@@ -21,6 +21,13 @@ type HostConnection = {
token: string;
};
type ConsultationReply = {
response: string;
suggestedQuestions: string[];
replyParseError?: string;
unparsedResponse?: string;
};
type TeacherCatalogFixture = {
nextTeacherReplyIncomplete?: boolean;
teacherCatalog: TeacherCatalog;
@@ -97,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[];
@@ -575,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);
}
}
@@ -843,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) => {
@@ -1965,6 +1980,57 @@ test('incomplete consultation replies retry only on click and preserve both draf
} 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);