feat(teacher): stream Yuxi reasoning and answer previews

This commit is contained in:
2026-09-29 18:18:56 +08:00
parent f443f3bc66
commit 8ccc4650a2
12 changed files with 364 additions and 42 deletions

View File

@@ -26,6 +26,8 @@ type ConsultationReply = {
suggestedQuestions: string[];
replyParseError?: string;
unparsedResponse?: string;
status?: 'running' | 'completed';
reasoning?: string;
};
type TeacherCatalogFixture = {
@@ -2575,3 +2577,56 @@ test('work failure offers a useful recovery and retains the student draft after
expect(new Set(opens.map((item) => item.body?.request_id)).size).toBe(2);
expect(requests.filter((item) => item.path.endsWith('/prompt'))).toHaveLength(0);
});
test('teacher reasoning and answer stream separately before the final response', 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, false, false, undefined, false, {
response: '', suggestedQuestions: [], status: 'running', reasoning: '先观察项目代码。',
});
await settleSnapshot(app);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('帮我看看项目');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
const reasoning = teacher.getByTestId('teacher-reasoning');
await expect(reasoning).toHaveAttribute('open');
await expect(reasoning.getByText('先观察项目代码。', { exact: true })).toBeVisible();
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
const send = (await readState(app)).captured.find(item => item.path.endsWith('/messages') && item.method === 'POST')!;
const current = await page.evaluate(async path => {
const result = await window.electron.ipcRenderer.invoke('hostapi:fetch', { path, method: 'GET' }) as {
data: { json: { topic: import('../../shared/coding-teacher').TeacherTopic } };
};
return result.data.json.topic;
}, send.path.replace(/\/messages$/, ''));
current.requests[0].reasoning += '再检查交互。'; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(reasoning).toContainText('先观察项目代码。再检查交互。');
current.requests[0].response = '可以先'; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(teacher.getByTestId('teacher-reply')).toHaveText('可以先');
await expect(teacher.getByRole('button', { name: '停止回复', exact: true })).toBeVisible();
current.requests[0].response = '可以先观察玩家反馈。'; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(teacher.getByTestId('teacher-reply')).toHaveText('可以先观察玩家反馈。');
await teacher.screenshot({ path: test.info().outputPath('teacher-streaming.png') });
current.requests[0].status = 'completed'; current.requests[0].suggestedQuestions = ['怎么观察?']; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(reasoning).not.toHaveAttribute('open');
await reasoning.locator('summary').click();
await expect(reasoning.getByLabel('智能体思考过程')).toBeVisible();
await expect(teacher.getByRole('button', { name: '怎么观察?', exact: true })).toBeVisible();
await expect(teacher.getByTestId('teacher-reply')).not.toContainText('检查交互');
expect((await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1);
} finally { await releaseSnapshot(app); }
});

View File

@@ -759,6 +759,7 @@ it.each(['suggestions', 'reply-v1', 'discussion-v1'] as const)(
let firstReads = 0, resumedReads = 0;
const text = vi.fn();
const activity = vi.fn();
const stream = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: requestId, run_id: 'before-read' };
@@ -783,18 +784,31 @@ it.each(['suggestions', 'reply-v1', 'discussion-v1'] as const)(
payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] },
});
if (url.includes('/before-read/')) {
accept('message', { thread_id: 'teacher-thread', payload: { items: [{ stream_event: {
type: 'message_delta', message_id: 'preamble', reasoning_content: '需要先读取项目。',
} }] } }, '0-1');
accept('message', message('teacher-thread', 'preamble', '我先检查重力。'), '1-0');
accept('message', message('teacher-thread', 'draft', '{"reply":"读取前的草案"}'), '2-0');
} else {
accept('message', message('child-thread', 'child', '子线程内容'), '1-0');
accept('message', { thread_id: 'teacher-thread', payload: { items: [{ stream_event: {
type: 'message_delta', message_id: 'final', reasoning_content: '已经获得项目证据。',
} }] } }, '1-1');
accept('message', message('teacher-thread', 'final', finalOutput.slice(0, 12)), '2-0');
expect(stream).toHaveBeenLastCalledWith({ response: finalOutput.slice(0, 12) });
expect(text).not.toHaveBeenCalled();
}
}),
};
await prepareCloudTeacher(
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity, stream
).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text);
expect(text.mock.calls).toEqual([[finalOutput]]);
expect(stream.mock.calls).toContainEqual([{ response: '' }]);
expect(stream.mock.calls).toContainEqual([{ response: finalOutput.slice(0, 12) }]);
expect(stream.mock.calls.flatMap(([update]) => update.reasoning ?? []).join(''))
.toBe('需要先读取项目。\n\n已经获得项目证据。');
expect(JSON.stringify(stream.mock.calls)).not.toContain('子线程内容');
expect(activity.mock.calls).toEqual([
[{ id: requestId + ':file', name: 'read_project_file', status: 'running' }],
[{ id: requestId + ':file', name: 'read_project_file', status: 'completed' }],
@@ -870,3 +884,38 @@ it('keeps only the cloud main-thread answer while advancing past child events on
expect(transport.events).toHaveBeenCalledTimes(2);
expect(streamUrls[1]).toContain('after_seq=2-0');
});
it('streams main-thread reasoning through reconnect and drains a completed run before finalizing', async () => {
const f = await fixture();
f.topic.requests.push({ id: requestId, text: '检查', references: [], createdAt: 'now',
sourceCursor: f.access.source.cursor, sourceCapturedAt: 'now', includedSourceMessageIds: [],
omittedMessages: 0, status: 'running', response: '', presentation: 'reply-v1' });
let reads = 0;
const stream = vi.fn(), text = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'one' }
: ++reads === 1 ? { status: 'running', thread_id: 'teacher-thread' }
: { status: 'completed', thread_id: 'teacher-thread', output: '{"reply":"最后正文","quickReplies":[]}' }),
events: vi.fn(async (url, _signal, accept) => {
const event = (thread_id: string, message_id: string, fields: Record<string, string>) => ({
thread_id, payload: { chunk: { stream_event: { type: 'message_delta', message_id, ...fields } } },
});
if (url.endsWith('0-0')) {
accept('message', event('teacher-thread', 'answer', { reasoning_content: '先看代码。' }), '1-0');
expect(stream).toHaveBeenLastCalledWith({ reasoning: '先看代码。' });
accept('message', event('child-thread', 'child', { reasoning_content: 'child-private' }), '2-0');
throw new Error('stream interrupted');
}
expect(url).toContain('after_seq=2-0');
accept('message', event('teacher-thread', 'answer', { additional_reasoning_content: '再确认结果。' }), '3-0');
accept('message', event('teacher-thread', 'answer', { content: '{"reply":"最后' }), '4-0');
expect(stream).toHaveBeenLastCalledWith({ response: '{"reply":"最后' });
expect(text).not.toHaveBeenCalled();
}),
};
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest,
transport, undefined, stream).run([], new AbortController().signal, text);
expect(stream.mock.calls.flatMap(([update]) => update.reasoning ?? []).join('')).toBe('先看代码。再确认结果。');
expect(text.mock.calls).toEqual([['{"reply":"最后正文","quickReplies":[]}']]);
expect(transport.events).toHaveBeenCalledTimes(2);
});

View File

@@ -1633,3 +1633,36 @@ describe('project consultations with selected cloud teachers', () => {
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
});
});
it.each(['completed', 'cancelled'] as const)('persists streamed reasoning and prose separately when %s', async terminal => {
const f = await fixture({ cloudTeacher: true, mockCloud: true });
const scope = { ...f.scope, sourceId: 'project' };
const topic = await f.service.create(scope, undefined, undefined, 9);
f.replyWith(terminal === 'completed' ? '{"reply":"最终答案","quickReplies":["继续"]}' : '');
const requestId = crypto.randomUUID();
const seen: TeacherTopic[] = [];
const unsubscribe = await f.service.subscribe(scope, topic.id, next => seen.push(next));
await f.service.send(scope, topic.id, { requestId, text: '检查项目' });
const update = f.prepareCloud.mock.calls[0][8] as (value: { response?: string; reasoning?: string }) => void;
update({ reasoning: '先分析当前代码。' });
update({ response: '{"reply":"正在生成' });
const live = await f.service.read(scope, topic.id);
expect(live.requests[0]).toMatchObject({ status: 'running', reasoning: '先分析当前代码。', response: '正在生成' });
expect(seen.at(-1)!.requests[0].response).toBe('正在生成');
update({ response: '' });
expect((await f.service.read(scope, topic.id)).requests[0].response).toBe('');
update({ response: '{"reply":"新的正文' });
if (terminal === 'cancelled') await f.service.cancel(scope, topic.id, requestId);
else f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe(terminal));
unsubscribe();
const restarted = await f.restart();
const saved = (await restarted.read(scope, topic.id)).requests[0];
expect(saved.reasoning).toBe('先分析当前代码。');
expect(saved.response).toBe(terminal === 'completed' ? '最终答案' : '新的正文');
if (terminal === 'cancelled') expect(saved.unparsedResponse).toBe('{"reply":"新的正文');
const nextId = crypto.randomUUID();
await restarted.send(scope, topic.id, { requestId: nextId, text: '接着解释' });
expect(JSON.stringify(f.run.mock.calls.at(-1)![0])).not.toContain('先分析当前代码。');
await restarted.cancel(scope, topic.id, nextId);
});

View File

@@ -180,3 +180,28 @@ describe('continuous agent panel', () => {
expect(api.sendConversation).not.toHaveBeenCalled();
});
});
it.each([undefined, 'suggestions'] as const)('updates reasoning and answer independently before completion (%s)', async intent => {
let current = topic([{ ...request('streaming'), intent, presentation: 'reply-v1', status: 'running', response: '' }]);
api.conversation.mockResolvedValue({ topic: current, before: null });
api.seenConversation.mockImplementation(async () => current);
await mount();
await waitFor(() => expect(api.events).toHaveBeenCalled());
const emit = async (patch: Partial<TeacherRequest>) => {
current = { ...current, revision: current.revision + 1, requests: [{ ...current.requests[0], ...patch }] };
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })));
};
await emit({ reasoning: '先读项目。' });
expect(screen.getByTestId('teacher-reasoning')).toHaveAttribute('open');
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先读项目。');
await emit({ reasoning: '先读项目。再分析玩法。', response: '正在生成正文' });
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('正在生成正文');
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('再分析玩法');
expect(screen.getByRole('button', { name: '停止回复' })).toBeEnabled();
await emit({ status: 'completed', response: '完整正文', suggestedQuestions: ['再讲一个例子'] });
expect(screen.getByTestId('teacher-reasoning')).not.toHaveAttribute('open');
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('完整正文');
expect(screen.getByRole('button', { name: '再讲一个例子' })).toBeEnabled();
fireEvent.click(screen.getByText('思考过程'));
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('再分析玩法。');
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { previewTeacherReply } from '../../shared/teacher-reply';
describe('teacher streaming prose', () => {
it('decodes text before the envelope or string closes, withholding incomplete escapes', () => {
const reply = '先看 "代码"。\n路径 C:\\game\n🍎';
const raw = JSON.stringify({ reply, quickReplies: ['下一步'], tool: { reply: 'never-visible' } });
const previews = Array.from({ length: raw.length + 1 }, (_, i) => previewTeacherReply(raw.slice(0, i)));
for (const preview of previews) {
expect(reply.startsWith(preview)).toBe(true);
expect(preview).not.toContain('quickReplies');
}
expect(previews[raw.indexOf('代码') + 2]).toBe('先看 "代码');
expect(previews.at(-1)).toBe(reply);
expect(previewTeacherReply('{"reply":"你好\\u4')).toBe('你好');
expect(previewTeacherReply('{"reply":"你好\\u4e16')).toBe('你好世');
});
it.each(['```json\n', '```JSON\r\n', '~~~json\n', '```makelore-teacher-reply\n', '```\n'])(
'streams fenced replies without leaking the protocol (%s)', fence => {
const raw = fence + '{"quickReplies":[],"reply":"逐步回答';
for (let i = 0; i <= raw.length; i++) {
const preview = previewTeacherReply(raw.slice(0, i));
expect('逐步回答'.startsWith(preview)).toBe(true);
}
expect(previewTeacherReply(raw)).toBe('逐步回答');
},
);
it('accepts plain text and completed ordinary JSON while excluding nested retired payloads', () => {
expect(previewTeacherReply('## 标题\n\n逐步说明')).toBe('## 标题\n\n逐步说明');
expect(previewTeacherReply('{"count":1}')).toBe('{"count":1}');
expect(previewTeacherReply('{"tool":{"reply":"不要显示"},"reply":"正文')).toBe('正文');
expect(previewTeacherReply('{"intro":"旧版正文","questions":[')).toBe('旧版正文');
expect(previewTeacherReply('{"reply":"正文","quickReplies":["隐藏')).toBe('正文');
});
});