feat(teacher): stream Yuxi reasoning and answer previews
This commit is contained in:
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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('再分析玩法。');
|
||||
});
|
||||
|
||||
38
tests/unit/teacher-reply-stream.test.ts
Normal file
38
tests/unit/teacher-reply-stream.test.ts
Normal 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('正文');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user