Merge Yuxi teacher support with classroom discussions
This commit is contained in:
156
tests/unit/coding-teacher-model.test.ts
Normal file
156
tests/unit/coding-teacher-model.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as cloud from '../../electron/coding-teacher/config-client';
|
||||
import * as transport from '../../electron/utils/proxy-fetch';
|
||||
import { prepareTeacherModel } from '../../electron/coding-teacher/model-runner';
|
||||
import type { TeacherDefinition } from '../../shared/coding-teacher';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const account: cloud.TeacherAccount = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
binding: { accountKey: 'teacher-test', epoch: 1 },
|
||||
};
|
||||
|
||||
// JSON round trip reproduces the cloud/durable-topic boundary, including Pydantic nulls.
|
||||
function definition(choice: unknown): TeacherDefinition {
|
||||
return JSON.parse(JSON.stringify({
|
||||
schema_version: 1, teacher_id: 'coding-teacher', name: 'Teacher',
|
||||
description: '', avatar_id: 'avatar-01', welcome_message: '',
|
||||
suggested_questions: [], system_prompt: 'Explain code.', skills: [],
|
||||
model: { model_id: 'deepseek-flash', reasoning_choice: choice },
|
||||
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||||
}));
|
||||
}
|
||||
|
||||
function setup(canDisable = true) {
|
||||
vi.spyOn(cloud, 'teacherCloudRequest').mockResolvedValue({
|
||||
api_key: 'synthetic-key', base_url: 'https://teacher-model.invalid/v1',
|
||||
models: ['deepseek-flash'],
|
||||
model_capabilities_v2: {
|
||||
schema_version: 2, models: {
|
||||
'deepseek-flash': {
|
||||
input_modalities: ['text', 'image'], output_modalities: ['text'],
|
||||
reasoning: {
|
||||
supported: true, can_disable: canDisable, default_enabled: true,
|
||||
effort_values: ['low', 'high', 'max'], default_effort: 'high',
|
||||
control_format: 'deepseek',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.spyOn(cloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||||
return vi.spyOn(transport, 'proxyAwareFetch').mockResolvedValue(new Response(
|
||||
'data: {"choices":[{"delta":{"content":"Explanation"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
||||
{ headers: { 'content-type': 'text/event-stream' } },
|
||||
));
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('teacher published reasoning wire contract', () => {
|
||||
it.each(['我先看一下文件。', '{"reply":"读取前的草案","tool":null}'])('delivers only the final structured answer after a tool round containing %s', async (preamble) => {
|
||||
const fetch = setup();
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'teacher-final-model-'));
|
||||
const answer = JSON.stringify({ reply: '重力是 0.6。', quickReplies: [], tool: null });
|
||||
const onText = vi.fn();
|
||||
try {
|
||||
await writeFile(path.join(root, 'game.ts'), 'export const gravity = 0.6;');
|
||||
fetch.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: {
|
||||
content: preamble,
|
||||
tool_calls: [{ index: 0, id: 'read-gravity', function: { name: 'read_project_file', arguments: '{"path":"game.ts"}' } }],
|
||||
}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 100, completion_tokens: 15 } }) + '\n\n'));
|
||||
fetch.mockImplementationOnce(async () => {
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
return new Response([
|
||||
{ choices: [{ delta: { content: answer.slice(0, 12) } }] },
|
||||
{ choices: [{ delta: { content: answer.slice(12) }, finish_reason: 'stop' }], usage: { prompt_tokens: 150, completion_tokens: 20 } },
|
||||
].map(event => 'data: ' + JSON.stringify(event) + '\n\n').join(''));
|
||||
});
|
||||
const prepared = await prepareTeacherModel(account, definition({ mode: 'disabled' }), {
|
||||
projectPath: root,
|
||||
source: { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: '2026-09-22' },
|
||||
assertCurrent: () => undefined,
|
||||
}, { finalOnly: true });
|
||||
const usage = await prepared.run([{ role: 'user', content: '用 JSON 解释重力' }], new AbortController().signal, onText);
|
||||
expect(onText).toHaveBeenCalledExactlyOnceWith(answer);
|
||||
expect(usage).toEqual({ inputTokens: 250, outputTokens: 35 });
|
||||
const continuation = JSON.parse(String(fetch.mock.calls[1][1]?.body));
|
||||
expect(continuation.messages.at(-2).content).toBe(preamble);
|
||||
expect(continuation.messages.at(-1).content).toContain('export const gravity = 0.6;');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not deliver a structured answer from an interrupted final stream', async () => {
|
||||
const fetch = setup();
|
||||
fetch.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: {
|
||||
content: '{"reply":"没有结束标志","tool":null}',
|
||||
} }] }) + '\n\n'));
|
||||
const prepared = await prepareTeacherModel(account, definition({ mode: 'disabled' }), undefined, { finalOnly: true });
|
||||
const onText = vi.fn();
|
||||
await expect(prepared.run([{ role: 'user', content: '解释一下' }], new AbortController().signal, onText))
|
||||
.rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads a current-project file requested by the model and returns its result for the answer', async () => {
|
||||
const fetch = setup();
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'teacher-model-files-'));
|
||||
try {
|
||||
await writeFile(path.join(root, 'game.ts'), 'export const gravity = 0.6;');
|
||||
fetch.mockResolvedValueOnce(new Response([
|
||||
{ choices: [{ delta: { content: '先读取文件。', tool_calls: [{ index: 0, id: 'call-read', type: 'function', function: { name: 'read_project_file', arguments: '{"path":"game.' } }] } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: 'ts"}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 100, completion_tokens: 15 } },
|
||||
].map(event => 'data: ' + JSON.stringify(event) + '\n\n').join('') + 'data: [DONE]\n\n'));
|
||||
const prepared = await prepareTeacherModel(account, definition({ mode: 'disabled' }), {
|
||||
projectPath: root,
|
||||
source: { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: '2026-09-22' },
|
||||
assertCurrent: () => undefined,
|
||||
});
|
||||
const onText = vi.fn();
|
||||
await prepared.run([{ role: 'user', content: '查看 game.ts 的重力设置' }], new AbortController().signal, onText);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const first = JSON.parse(String(fetch.mock.calls[0][1]?.body));
|
||||
expect(first.tools.map((tool: { function: { name: string } }) => tool.function.name)).toContain('read_project_file');
|
||||
const second = JSON.parse(String(fetch.mock.calls[1][1]?.body));
|
||||
expect(second.messages.at(-1)).toMatchObject({ role: 'tool', tool_call_id: 'call-read' });
|
||||
expect(second.messages.at(-1).content).toContain('export const gravity = 0.6;');
|
||||
expect(onText.mock.calls.map(call => call[0])).toEqual(['先读取文件。', 'Explanation']);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ mode: 'enabled', effort: null }, { thinking: { type: 'enabled' } }],
|
||||
[{ mode: 'enabled' }, { thinking: { type: 'enabled' } }],
|
||||
[{ mode: 'disabled', effort: null }, { thinking: { type: 'disabled' } }],
|
||||
[{ mode: 'default', effort: null }, {}],
|
||||
[{ mode: 'enabled', effort: 'high' }, { thinking: { type: 'enabled' }, reasoning_effort: 'high' }],
|
||||
])('prepares saved choice %j and sends its native controls', async (choice, fields) => {
|
||||
const fetch = setup();
|
||||
const prepared = await prepareTeacherModel(account, definition(choice));
|
||||
const onText = vi.fn();
|
||||
await prepared.run([{ role: 'user', content: 'Explain this.' }], new AbortController().signal, onText);
|
||||
expect(onText).toHaveBeenCalledWith('Explanation');
|
||||
const body = JSON.parse(String(fetch.mock.calls[0][1]?.body));
|
||||
expect({
|
||||
...(body.thinking === undefined ? {} : { thinking: body.thinking }),
|
||||
...(body.reasoning_effort === undefined ? {} : { reasoning_effort: body.reasoning_effort }),
|
||||
}).toEqual(fields);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ mode: 'enabled', effort: 'unsupported' }, true],
|
||||
[{ mode: 'disabled', effort: null }, false],
|
||||
])('keeps rejecting unsupported choice %j', async (choice, canDisable) => {
|
||||
const fetch = setup(Boolean(canDisable));
|
||||
await expect(prepareTeacherModel(account, definition(choice)))
|
||||
.rejects.toThrow('老师所用思考选项已不可用,请联系运营调整。');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user