// @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'; import { compileTeacherContext, estimateTeacherTokens } from '../../electron/coding-teacher/context'; import { discussionInstructions } from '../../electron/coding-teacher/discussion'; import type { TeacherTopic } from '../../shared/coding-teacher'; 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('accepts a short discussion question with the existing 8000 budget and teaching prompt', async () => { const fetch = setup(); const config = definition({ mode: 'disabled' }); config.system_prompt = '帮助学生理解设计,结合实际进展给出建议。'.repeat(45); const source = { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: '2026-09-24' }; const topic = { definition: config } as TeacherTopic; const prepared = await prepareTeacherModel(account, config, { projectPath: tmpdir(), source, assertCurrent: () => undefined, }, { finalOnly: true }); const protocol = discussionInstructions(topic); const fixed = compileTeacherContext(config, source, [], '帮我看看我的项目有啥问题', [], Infinity, 'question', protocol, true); expect(estimateTeacherTokens(fixed.messages)).toBeLessThanOrEqual(prepared.inputLimit); const compiled = compileTeacherContext(config, source, [], '帮我看看我的项目有啥问题', [], prepared.inputLimit, 'question', protocol, true); await prepared.run(compiled.messages, new AbortController().signal, vi.fn()); expect(fetch).toHaveBeenCalledOnce(); }); it('reports oversized fixed instructions as a configuration problem without dispatching', async () => { const fetch = setup(); const config = definition({ mode: 'disabled' }); config.limits.max_input_tokens = 3000; config.system_prompt = '运营配置的超长说明。'.repeat(1000); const source = { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: 'now' }; const prepared = await prepareTeacherModel(account, config, { projectPath: tmpdir(), source, assertCurrent: () => undefined, }); expect(() => compileTeacherContext(config, source, [], '你好', [], prepared.inputLimit, 'question', discussionInstructions({ definition: config } as TeacherTopic), true)) .toThrow('智能体配置或当前整理内容超过上下文预算'); expect(fetch).not.toHaveBeenCalled(); }); 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(); }); });