| undefined;
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
export const teacherApi = {
diff --git a/tests/e2e/fixtures/teacher-discussion-layout.tsx b/tests/e2e/fixtures/teacher-discussion-layout.tsx
index 80ebcb2c..3cfddb65 100644
--- a/tests/e2e/fixtures/teacher-discussion-layout.tsx
+++ b/tests/e2e/fixtures/teacher-discussion-layout.tsx
@@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client';
import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.css';
+import 'katex/dist/katex.min.css';
const width = Number(new URLSearchParams(location.search).get('width')) || 508;
createRoot(document.getElementById('root')!).render(
智能体讨论交互测试 · 真实页面组件 / 模拟回复
diff --git a/tests/e2e/teacher-discussion-layout.spec.ts b/tests/e2e/teacher-discussion-layout.spec.ts
index 7edcbd71..7651e745 100644
--- a/tests/e2e/teacher-discussion-layout.spec.ts
+++ b/tests/e2e/teacher-discussion-layout.spec.ts
@@ -20,6 +20,34 @@ test.beforeAll(async () => {
await server.listen(); url = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
+for (const width of [319, 508]) {
+ test(`rich replies keep code, tables and images inside the panel at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width: 1180, height: 800 });
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.route('https://example.com/teacher-preview.png', route => route.fulfill({
+ contentType: 'image/svg+xml',
+ body: '',
+ }));
+ await page.goto(`${url}?kind=rich-text&width=${width}`);
+ const body = page.getByTestId('teacher-reply');
+ await expect(body.getByRole('heading', { name: '项目建议' })).toHaveCount(1);
+ await expect(body.getByRole('table')).toHaveCount(1);
+ await expect(body.locator('.katex')).toHaveCount(1);
+ await expect(body.getByRole('img', { name: '项目截图' })).toBeVisible();
+ await expect.poll(() => body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
+ await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
+ const raw = page.getByText('查看收到的原始内容');
+ await expect(raw.locator('..')).not.toHaveAttribute('open');
+ await raw.click();
+ await expect(raw.locator('..').locator('pre')).toContainText('"tool"');
+ const input = page.getByRole('textbox', { name: '向智能体提问' });
+ await expect(input).toBeVisible();
+ await page.locator('.consultation-messages').evaluate(el => { el.scrollTop = 0; });
+ await page.screenshot({ path: test.info().outputPath(`rich-reply-${width}.png`) });
+ expect(errors).toEqual([]);
+ });
+}
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
test(`${kind} pins the current tool and composer at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts
index 95ea5d4b..8ca61e37 100644
--- a/tests/unit/coding-teacher-cloud.test.ts
+++ b/tests/unit/coding-teacher-cloud.test.ts
@@ -113,6 +113,44 @@ it('submits only the published persona, source evidence and current question in
expect(body.local_context.tools).toEqual(['list_project_files', 'read_project_file', 'read_conversation']);
});
+it.each([null, { content: 'not a supported output field' }, ['text block']])('reports unsupported final output instead of silently losing %j', async output => {
+ const f = await fixture();
+ const text = vi.fn();
+ const transport: TeacherCloudTransport = {
+ json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'run' } : { status: 'completed', output }),
+ events: vi.fn(),
+ };
+ await expect(prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport)
+ .run([{ role: 'user', content: '问题' }], new AbortController().signal, text))
+ .rejects.toMatchObject({ code: 'teacher_protocol_invalid' });
+ expect(text).not.toHaveBeenCalled();
+});
+
+it('merges typed tool activity across replay without adding tool data or child events to the answer', async () => {
+ const f = await fixture();
+ let reads = 0;
+ const activity = vi.fn(), text = vi.fn();
+ const transport: TeacherCloudTransport = {
+ json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'run' }
+ : ++reads === 1 ? { status: 'running', thread_id: 'main' } : { status: 'completed', output: '最终正文' }),
+ events: vi.fn(async (_url, _signal, accept) => {
+ const start = { stream_event: { type: 'tool_call', tool_call_id: 'call', name: 'search', args: { private: 'private-args' } } };
+ const finish = { status: 'stream_event', event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'call', output: { status: 'success', content: 'private-result' } } } };
+ accept('messages', { thread_id: 'child', payload: { items: [start] } }, '1-0');
+ accept('messages', { thread_id: 'main', payload: { items: [start] } }, '2-0');
+ accept('custom', { thread_id: 'main', payload: { chunk: finish } }, '3-0');
+ accept('messages', { thread_id: 'main', payload: { items: [start] } }, '4-0');
+ }),
+ };
+ await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity)
+ .run([{ role: 'user', content: '问题' }], new AbortController().signal, text);
+ expect(activity.mock.calls).toEqual([
+ [{ id: 'run:call', name: 'search', status: 'running' }],
+ [{ id: 'run:call', name: 'search', status: 'completed' }],
+ ]);
+ expect(text.mock.calls).toEqual([['最终正文']]);
+});
+
it('submits the active discussion protocol, current tool content and selected focus', async () => {
const f = await fixture();
f.topic.definition.limits.max_input_tokens = 16000;
@@ -448,6 +486,7 @@ it.each(['suggestions', 'discussion-v1'] as const)(
const finalOutput = JSON.stringify(result);
let firstReads = 0, resumedReads = 0;
const text = vi.fn();
+ const activity = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: requestId, run_id: 'before-read' };
@@ -481,9 +520,13 @@ it.each(['suggestions', 'discussion-v1'] as const)(
}),
};
await prepareCloudTeacher(
- f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport
+ f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity
).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text);
expect(text.mock.calls).toEqual([[finalOutput]]);
+ expect(activity.mock.calls).toEqual([
+ [{ id: 'before-read:file', name: 'read_project_file', status: 'running' }],
+ [{ id: 'before-read:file', name: 'read_project_file', status: 'completed' }],
+ ]);
const response = text.mock.calls.map(([delta]) => delta).join('');
expect(format === 'suggestions'
? parseTeacherSuggestions(response)
diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx
index f4f3d658..dfefddbd 100644
--- a/tests/unit/coding-teacher-ui.test.tsx
+++ b/tests/unit/coding-teacher-ui.test.tsx
@@ -96,6 +96,19 @@ async function ready() {
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
+ it('renders assistant Markdown, tables, code and images instead of literal markup', async () => {
+ const response = '## 项目建议\n\n**先验证跳跃**\n\n- 保留现有玩法\n\n| 文件 | 用途 |\n| --- | --- |\n| main.ts | 入口 |\n\n```json\n{"name":"flybot"}\n```\n\n[说明](https://example.com/readme)\n\n';
+ api.read.mockResolvedValue({ ...first, requests: [request({ response })] });
+ render();
+ await ready();
+ expect(screen.getByRole('heading', { name: '项目建议' })).toBeVisible();
+ expect(screen.getByRole('table')).toHaveTextContent('main.ts');
+ expect(screen.getByText('保留现有玩法').closest('li')).not.toBeNull();
+ expect(screen.getByText('{"name":"flybot"}').closest('pre')).not.toBeNull();
+ expect(screen.getByRole('link', { name: '说明' })).toHaveAttribute('href', 'https://example.com/readme');
+ expect(screen.getByRole('img', { name: '项目截图' })).toHaveAttribute('src', 'https://example.com/preview.png');
+ });
+
it('does not render a zero for an empty legacy friend draft', async () => {
localStorage.setItem('makelore-consultation-draft:signed-out:p:friend', JSON.stringify({ text: '', references: [] }));
render();
@@ -105,6 +118,68 @@ describe('teacher side chat', () => {
expect(screen.queryByText('查看旧版朋友草稿')).not.toBeInTheDocument();
});
+ it('keeps malformed originals folded and literal while rendering the recovered answer', async () => {
+ const raw = '{"reply":"正文已保留","tool":';
+ api.read.mockResolvedValue({ ...first, requests: [request({
+ response: '**正文已保留**', discussionError: '这次整理没有完成,先保留原来的内容。', unparsedResponse: raw,
+ })] });
+ render();
+ await ready();
+ expect(screen.getByText('正文已保留').tagName).toBe('STRONG');
+ const details = screen.getByText('查看收到的原始内容').closest('details');
+ expect(details).not.toHaveAttribute('open');
+ expect(details?.querySelector('pre')?.textContent).toBe(raw);
+ expect(details?.querySelector('script')).toBeNull();
+ });
+
+ it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
+ api.read.mockResolvedValue({ ...first, requests: [request({
+ response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n\n\n\n\n',
+ })] });
+ render();
+ await ready();
+ const body = screen.getByTestId('teacher-reply');
+ expect(body.querySelector('.katex')).not.toBeNull();
+ expect(body.querySelector('pre')?.textContent).toContain('const value = "\\n";');
+ expect(body.querySelector('script')).toBeNull();
+ expect(screen.queryByRole('link', { name: '危险' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: '本地图片' })).not.toBeInTheDocument();
+ fireEvent.error(screen.getByRole('img', { name: '坏图片' }));
+ expect(screen.getByText('[图片未能显示:坏图片]')).toBeVisible();
+ });
+
+ it('updates streamed Markdown without duplicating the final answer', async () => {
+ const running = { ...first, requests: [request({ status: 'running', response: '## 正在回答\n\n**先检查' })] };
+ api.read.mockResolvedValue(running);
+ render();
+ await ready();
+ expect(screen.getByRole('heading', { name: '正在回答' })).toBeVisible();
+ const completed = { ...running, revision: 2, requests: [request({ response: '## 正在回答\n\n**先检查文件**' })] };
+ await act(async () => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(completed) })));
+ expect(screen.getAllByTestId('teacher-reply')).toHaveLength(1);
+ expect(screen.getByText('先检查文件').tagName).toBe('STRONG');
+ });
+
+ it('keeps tool activity separate from the answer and stops stale running indicators', async () => {
+ api.read.mockResolvedValue({ ...first, requests: [request({
+ response: '已经看过项目文件。',
+ toolActivity: [
+ { id: 'one', name: 'read_project_file', status: 'completed' },
+ { id: 'two', name: 'read_conversation', status: 'failed' },
+ { id: 'three', name: 'cloud-search', status: 'running' },
+ ],
+ })] });
+ render();
+ await ready();
+ const details = screen.getByText('使用了 3 项工具').closest('details')!;
+ expect(details).not.toHaveAttribute('open');
+ expect(details).toHaveTextContent('读取项目文件已完成');
+ expect(details).toHaveTextContent('读取会话原文未完成');
+ expect(details).toHaveTextContent('未收到执行结果');
+ expect(screen.getByTestId('teacher-reply')).toHaveTextContent('已经看过项目文件。');
+ expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('读取会话原文');
+ });
+
it('retains failed input and retries with the same request id, then appends a reply only on click', async () => {
const bringBack = vi.fn();
api.send
diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts
index 025e688b..ade3d073 100644
--- a/tests/unit/coding-teacher.test.ts
+++ b/tests/unit/coding-teacher.test.ts
@@ -198,6 +198,9 @@ describe('cloud coding teacher', () => {
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
expect(saved.response).toBe('计数器从 42 开始。');
+ expect(saved.toolActivity).toEqual([{ id: 'parent:read-counter', name: 'read_project_file', status: 'completed' }]);
+ const restarted = await f.restart();
+ expect((await restarted.read(f.scope, topic.id)).requests[0].toolActivity).toEqual(saved.toolActivity);
expect(saved.cloudRequestId).toBe('cloud-question');
expect(JSON.stringify(requests[0].body)).toContain('const title');
expect(saved.truncatedMessages).toBeGreaterThan(0);
@@ -1377,6 +1380,25 @@ describe('structured teacher service integration', () => {
describe('project consultations with selected cloud teachers', () => {
+ it('persists malformed discussion output without putting it into future model context', async () => {
+ const f = await fixture();
+ const scope = { ...f.scope, sourceId: 'project' };
+ const selected = await f.service.create(scope);
+ const raw = '{"reply":"这段正文完整。","tool":{"privateDiagnostic":"only-in-original"';
+ f.replyWith(raw);
+ await f.service.send(scope, selected.id, {
+ requestId: crypto.randomUUID(), text: '看看项目', presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId,
+ });
+ f.finish();
+ await vi.waitFor(async () => expect((await f.service.read(scope, selected.id)).requests[0].status).toBe('completed'));
+ const restarted = await f.restart();
+ const saved = await restarted.read(scope, selected.id);
+ expect(saved.requests[0]).toMatchObject({ response: '这段正文完整。', unparsedResponse: raw });
+ const compiled = compileTeacherContext(saved.definition, context, saved.requests, '继续聊', []);
+ expect(JSON.stringify(compiled.messages)).not.toContain('only-in-original');
+ expect(JSON.stringify(compiled.messages)).toContain('这段正文完整。');
+ });
+
it.each(['local', 'yuxi'])('keeps the published identity and project tools on the %s runtime', async (runtime) => {
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
const scope = { ...f.scope, sourceId: 'project' };
diff --git a/tests/unit/teacher-cloud-activity.test.ts b/tests/unit/teacher-cloud-activity.test.ts
new file mode 100644
index 00000000..31dc36b9
--- /dev/null
+++ b/tests/unit/teacher-cloud-activity.test.ts
@@ -0,0 +1,38 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+import { cloudToolActivity } from '../../electron/coding-teacher/cloud-activity';
+
+describe('teacher cloud tool activity projection', () => {
+ it.each(['tool_call', 'tool_call_delta'])('takes only identity and state from %s', type => {
+ expect(cloudToolActivity({ stream_event: {
+ type, tool_call_id: 'call', name: 'read_project_file', args: { path: 'private.ts' }, args_delta: '{"secret":',
+ } }, 'run')).toEqual({ id: 'run:call', name: 'read_project_file', status: 'running' });
+ });
+ it.each([
+ ['tool-started', undefined, undefined, 'running'],
+ ['tool-finished', { type: 'tool', content: '{"private":"value"}', status: 'success' }, undefined, 'completed'],
+ ['tool-finished', 'result text', undefined, 'completed'],
+ ['tool-finished', { status: 'error', content: 'internal error' }, undefined, 'failed'],
+ ['tool-finished', undefined, 'internal stack trace', 'failed'],
+ ])('projects %s without leaking tool output', (event, output, error, status) => {
+ expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: {
+ event, tool_call_id: 'call', tool_name: 'read_conversation', output, error,
+ } } }, 'run')).toEqual({ id: 'run:call', name: 'read_conversation', status });
+ });
+ it('does not guess activity from answer text, reasoning or unidentified deltas', () => {
+ for (const chunk of [
+ { stream_event: { type: 'message_delta', content: '{"tool_call_id":"call","name":"read_project_file"}' } },
+ { stream_event: { type: 'message_delta', reasoning_content: 'internal reasoning' } },
+ { stream_event: { type: 'tool_call_delta', index: 0, args_delta: '{}' } },
+ { status: 'stream_event', event: { method: 'messages', data: { event: 'tool-started', tool_call_id: 'call', tool_name: 'test' } } },
+ ]) expect(cloudToolActivity(chunk, 'run')).toBeUndefined();
+ });
+ it.each([
+ ['tool-finished', { type: 'tool', content: 'tool result', status: 'success' }, undefined, 'completed'],
+ ['tool-error', undefined, 'private error', 'failed'],
+ ])('accepts real %s events that carry only the call id', (event, output, error, status) => {
+ expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: {
+ event, tool_call_id: 'call', output, error,
+ } } }, 'run')).toEqual({ id: 'run:call', status });
+ });
+});
diff --git a/tests/unit/teacher-discussion-state.test.ts b/tests/unit/teacher-discussion-state.test.ts
index f737197a..ef529e5a 100644
--- a/tests/unit/teacher-discussion-state.test.ts
+++ b/tests/unit/teacher-discussion-state.test.ts
@@ -304,6 +304,22 @@ describe('applying completed teacher replies', () => {
expect(turn.discussionSnapshot).toBeUndefined();
});
+ it('retains the original failed answer for inspection without changing the discussion', () => {
+ const owner = topic(flow, 'paused');
+ const before = structuredClone(owner.discussion);
+ const turn = request(owner);
+ const raw = '{"reply":"收到,先看看项目。","tool":{"kind":"flow",';
+ applyDiscussionReply(owner, turn, raw);
+ expect(turn.response).toBe('收到,先看看项目。');
+ expect(turn.unparsedResponse).toBe(raw);
+ expect(owner.discussion).toEqual(before);
+ const plainTurn = request(owner);
+ applyDiscussionReply(owner, plainTurn, '[项目说明](https://example.com)');
+ expect(plainTurn.discussionError).toBeUndefined();
+ expect(plainTurn.unparsedResponse).toBeUndefined();
+ expect(owner.discussion).toEqual(before);
+ });
+
it('keeps legacy text as a normal answer without creating a component', () => {
const owner = topic();
const turn = request(owner);
diff --git a/tests/unit/teacher-discussion.test.ts b/tests/unit/teacher-discussion.test.ts
index bfac06cc..defe1796 100644
--- a/tests/unit/teacher-discussion.test.ts
+++ b/tests/unit/teacher-discussion.test.ts
@@ -176,6 +176,20 @@ describe('teacher discussion model envelope', () => {
.toEqual({ reply: '柯基、冒险和菜园都是好点子。', quickReplies: [] });
});
+ it.each([
+ '[项目说明](https://example.com/readme)\n\n先看已有设计。',
+ '[1, 2, 3]',
+ '[{"reply":"普通数组示例"}]',
+ '```json\nnot valid json\n```',
+ '{"scripts":{"dev":"vite"},"name":"flybot"}',
+ '项目配置可以这样写:\n\n```json\n{"name":"flybot","scripts":{"dev":"vite"}}\n```',
+ '```json\n[{"id":1,"label":"入口"}]\n```',
+ '{"example":{"reply":"这是数据字段,不是回答协议"}}',
+ '```js\nconst example = {"reply":"代码示例"};\n```',
+ ])('preserves Markdown links and JSON examples as readable content: %s', raw => {
+ expect(parseTeacherDiscussionReply(raw)).toEqual({ reply: raw, quickReplies: [] });
+ });
+
it('distinguishes an absent tool from an explicit null and supports a tool-only envelope', () => {
expect(parseTeacherDiscussionReply('{"reply":"这轮只解释一下。"}'))
.toEqual({ reply: '这轮只解释一下。', quickReplies: [] });
@@ -213,8 +227,7 @@ describe('teacher discussion model envelope', () => {
'{"reply":"还没有说完',
'{"tool":{"reply":"不能拿嵌套结构充当正文"},',
'```json\n{"tool":{"secret":"raw-data"}}',
- '```json\nnot valid json\n```',
- '[{"reply":"不是合法 envelope"}]',
+ '```makelore-teacher-discussion\nnot valid json\n```',
'{"reply":13,"tool":{"kind":"html"}}',
'```json\n{"reply":"结束"}\n```\n```json\n{"reply":"第二个"}\n```',
])('never exposes malformed JSON as the student reply: %s', raw => {