fix: render consultation replies and typed tool activity
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
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(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
@@ -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":<script>alert(1)</script>';
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '**正文已保留**', discussionError: '这次整理没有完成,先保留原来的内容。', unparsedResponse: raw,
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
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<script>alert(1)</script>\n\n\n\n',
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
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(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
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(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
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
|
||||
|
||||
@@ -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' };
|
||||
|
||||
38
tests/unit/teacher-cloud-activity.test.ts
Normal file
38
tests/unit/teacher-cloud-activity.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user