1682 lines
95 KiB
TypeScript
1682 lines
95 KiB
TypeScript
// @vitest-environment node
|
||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
|
||
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
|
||
import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../../electron/coding-teacher/context';
|
||
const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师';
|
||
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
|
||
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
||
import { teacherReplyInstructions } from '../../electron/coding-teacher/reply';
|
||
import { createTeacherReadTools, type TeacherReadAccess } from '../../electron/coding-teacher/read-tools';
|
||
import {
|
||
createCodingProjectStore,
|
||
createMemoryCodingProjectStorage,
|
||
} from '../../electron/coding-projects/project-store';
|
||
import { CodingProjectService } from '../../electron/coding-projects/project-service';
|
||
import {
|
||
ensureDefaultCodingAgent,
|
||
createCodingProjectConfigV2,
|
||
} from '../../electron/coding-projects/project-config';
|
||
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
||
import type { LegacyConsultationRole, TeacherDefinition, TeacherRequest, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
|
||
import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher';
|
||
import * as teacherCloud from '../../electron/coding-teacher/config-client';
|
||
import * as teacherTransport from '../../electron/utils/proxy-fetch';
|
||
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
|
||
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
|
||
|
||
const definition: TeacherDefinition = {
|
||
schema_version: 1,
|
||
teacher_id: 'coding-teacher',
|
||
name: '编程老师',
|
||
description: '',
|
||
avatar_id: 'avatar-01',
|
||
welcome_message: '一起学编程',
|
||
suggested_questions: ['为什么?'],
|
||
system_prompt: '通过问题引导思考。',
|
||
skills: [
|
||
{
|
||
id: 'explain',
|
||
name: '讲解',
|
||
description: '',
|
||
enabled: true,
|
||
instructions_markdown: '使用具体的小例子。',
|
||
},
|
||
],
|
||
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
|
||
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||
};
|
||
const context: TeacherSourceContext = {
|
||
messages: [{ id: 'source-user', role: 'user', text: '创建计数器' }],
|
||
cursor: { workerGeneration: 1, seq: 3 },
|
||
capturedAt: '2026-09-22T00:00:00Z',
|
||
};
|
||
const roots: string[] = [];
|
||
const services: CodingTeacherService[] = [];
|
||
afterEach(async () => {
|
||
await Promise.all(services.splice(0).map((service) => service.dispose()));
|
||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||
vi.useRealTimers();
|
||
});
|
||
async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens, modelSystemPrompt = definition.system_prompt } = {}) {
|
||
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
|
||
roots.push(root);
|
||
const projects = new CodingProjectService(
|
||
createCodingProjectStore(createMemoryCodingProjectStorage())
|
||
);
|
||
const created = await projects.createProject({
|
||
projectPath: path.join(root, 'project'),
|
||
identity: { kind: 'create' },
|
||
});
|
||
const source = await projects
|
||
.conversationStore(created.project.path)
|
||
.create({
|
||
agentId: created.config.defaultAgentId!,
|
||
title: '源码会话',
|
||
model: null,
|
||
modelResolution: 'required',
|
||
});
|
||
const scope = { projectId: created.project.id, sourceId: source.id };
|
||
let enabled = true,
|
||
version = 1,
|
||
cloudVersion = 9,
|
||
accountCurrent = true;
|
||
let finish: () => void = () => undefined;
|
||
let reply = '计数器保存一个数字。';
|
||
const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => {
|
||
onText(reply);
|
||
await new Promise<void>((resolve, reject) => {
|
||
finish = resolve;
|
||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||
});
|
||
return { inputTokens: 20, outputTokens: 10 };
|
||
});
|
||
let account = {
|
||
id: '11111111-1111-4111-8111-111111111111',
|
||
binding: { accountKey: 'test', epoch: 1 },
|
||
};
|
||
const readSource = vi.fn(async (_scope: TeacherScope) => structuredClone(sourceContext));
|
||
const prepareModel = vi.fn(async () => ({ inputLimit: 8000, run }));
|
||
const prepareCloud = vi.fn((..._args: unknown[]) => ({ inputLimit: 8000, run }));
|
||
const createService = () => new CodingTeacherService({
|
||
projects,
|
||
models: async () => ({ supported: true, models: [{ id: 'qwen' }, { id: 'other-model' }] }),
|
||
runtime: new InMemoryConversationRuntime(),
|
||
userDataDir: root,
|
||
account: async () => account,
|
||
assertAccount: () => {
|
||
if (!accountCurrent) throw new TeacherError(401, 'teacher_account_changed', '账号变化');
|
||
},
|
||
availability: async () => ({ enabled, published_version: version, revision: version }),
|
||
catalog: cloudTeacher ? async () => ({ items: [{ teacher_id: 'cloud-teacher', version: cloudVersion, is_default: true,
|
||
definition: { ...definition, config_id: 'cloud-teacher', runtime: 'yuxi' as const, system_prompt: '', skills: [],
|
||
yuxi: { agent_slug: 'teacher', agent_version: 2 } } }] }) : undefined,
|
||
version: async (_account, v) => ({
|
||
version: v,
|
||
payload: { ...definition, name: '老师 v' + v, system_prompt: modelSystemPrompt, limits: { ...definition.limits, max_input_tokens: modelInputLimit } },
|
||
}),
|
||
preview: async (_account, revision) => {
|
||
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
|
||
return { draft_revision: revision, payload: definition };
|
||
},
|
||
readSource: durableSource ? undefined : readSource,
|
||
prepareModel: liveModel ? undefined : prepareModel,
|
||
prepareCloud: mockCloud ? prepareCloud : undefined,
|
||
});
|
||
const service = createService();
|
||
services.push(service);
|
||
return {
|
||
root,
|
||
projects,
|
||
created,
|
||
scope,
|
||
service,
|
||
run,
|
||
readSource,
|
||
prepareModel,
|
||
prepareCloud,
|
||
restart: async () => {
|
||
await service.dispose();
|
||
const restarted = createService();
|
||
services.push(restarted);
|
||
return restarted;
|
||
},
|
||
finish: () => finish(),
|
||
replyWith: (text: string) => { reply = text; },
|
||
disable: () => {
|
||
enabled = false;
|
||
},
|
||
nextVersion: () => {
|
||
version++;
|
||
},
|
||
publishCloudVersion: (next: number) => { cloudVersion = next; },
|
||
switchAccount: () => {
|
||
accountCurrent = false;
|
||
},
|
||
useOtherAccount: () => {
|
||
account = {
|
||
id: '99999999-9999-4999-8999-999999999999',
|
||
binding: { accountKey: 'another-account', epoch: 2 },
|
||
};
|
||
},
|
||
};
|
||
}
|
||
describe('cloud coding teacher', () => {
|
||
it.each([false, true])('persists terminal times for local/cloud=%s replies across restart', async cloudTeacher => {
|
||
const f = await fixture({ cloudTeacher, mockCloud: cloudTeacher });
|
||
const topic = await f.service.create(f.scope, undefined, undefined, cloudTeacher ? 9 : undefined);
|
||
const id = '22222222-2222-4222-8222-222222222222';
|
||
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释一下' });
|
||
const running = (await f.service.read(f.scope, topic.id)).requests[0];
|
||
expect(running.finishedAt).toBeUndefined();
|
||
const endingAfter = Date.now();
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].finishedAt).toBeTruthy());
|
||
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
|
||
expect(saved.createdAt).toBe(running.createdAt);
|
||
expect(Date.parse(saved.finishedAt!)).toBeGreaterThanOrEqual(endingAfter);
|
||
expect(Date.parse(saved.finishedAt!)).toBeLessThanOrEqual(Date.now());
|
||
const restarted = await f.restart();
|
||
expect((await restarted.read(f.scope, topic.id)).requests[0]).toEqual(saved);
|
||
});
|
||
it.each(['cancelled', 'failed'] as const)('also saves the actual terminal time for %s replies', async status => {
|
||
const f = await fixture();
|
||
if (status === 'failed') f.run.mockRejectedValueOnce(new Error('failure'));
|
||
const topic = await f.service.create(f.scope);
|
||
const id = '22222222-2222-4222-8222-222222222222';
|
||
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释一下' });
|
||
if (status === 'cancelled') await f.service.cancel(f.scope, topic.id, id);
|
||
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({ status, finishedAt: expect.any(String) }));
|
||
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
|
||
expect((await (await f.restart()).read(f.scope, topic.id)).requests[0].finishedAt).toBe(saved.finishedAt);
|
||
});
|
||
|
||
it('does not change the selected discussion when the companion polls an older topic', async () => {
|
||
const f = await fixture();
|
||
const old = await f.service.create(f.scope);
|
||
const selected = await f.service.create(f.scope);
|
||
expect((await f.service.read(f.scope, old.id, false)).id).toBe(old.id);
|
||
expect((await f.service.list(f.scope)).lastSelectedTopicId).toBe(selected.id);
|
||
});
|
||
it('wires the Yuxi topic through scoped credentials and local tools without a local prompt or model loop', async () => {
|
||
const f = await fixture({ cloudTeacher: true, sourceContext: {
|
||
...context, messages: [{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) }],
|
||
} });
|
||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||
scope: 'makelore-teachers', access_token: 'teacher-test', api_base_url: 'https://teacher.invalid',
|
||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||
});
|
||
const id = '22222222-2222-4222-8222-222222222222';
|
||
const requests: Array<{ path: string; body: unknown }> = [];
|
||
vi.spyOn(teacherTransport, 'proxyAwareFetch').mockImplementation(async (url, init) => {
|
||
const pathname = new URL(String(url)).pathname.replace('/api/makelore/teachers', '');
|
||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||
requests.push({ path: pathname, body });
|
||
if (pathname === '/questions') return Response.json({ request_id: 'cloud-question', run_id: 'parent' });
|
||
if (pathname === '/runs/parent') return Response.json({ status: 'interrupted', interrupt: {
|
||
source: 'client_read_tools', context_id: id, calls: [{ tool_call_id: 'read-counter', name: 'read_project_file', arguments: { path: 'counter.ts' } }],
|
||
} });
|
||
if (pathname.endsWith('/tool-results')) return Response.json({ run_id: 'continued' });
|
||
if (pathname === '/runs/continued') return Response.json({ status: 'completed', output: '计数器从 42 开始。' });
|
||
throw new Error('unexpected cloud call: ' + pathname);
|
||
});
|
||
try {
|
||
const topic = await f.service.create(f.scope, undefined, undefined, 9);
|
||
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释项目里的计数器' });
|
||
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: id + ':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);
|
||
expect(Buffer.byteLength((requests[0].body as { query: string }).query)).toBeLessThanOrEqual(8000);
|
||
expect(requests.find(item => item.path.endsWith('/tool-results'))?.body).toMatchObject({
|
||
context_id: id, results: [{ tool_call_id: 'read-counter', status: 'success', content: expect.stringContaining('let count = 42;') }],
|
||
});
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
} finally { vi.restoreAllMocks(); }
|
||
});
|
||
it('connects the scoped service, source context, file tools and model continuation', async () => {
|
||
const f = await fixture({ liveModel: true });
|
||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||
api_key: 'synthetic', base_url: 'https://teacher.invalid/v1', models: ['qwen'],
|
||
model_capabilities_v2: { schema_version: 2, models: {
|
||
qwen: { input_modalities: ['text'], output_modalities: ['text'], reasoning: { supported: false } },
|
||
} },
|
||
});
|
||
const fetch = vi.spyOn(teacherTransport, 'proxyAwareFetch')
|
||
.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: {
|
||
tool_calls: [{ index: 0, id: 'read-counter', function: { name: 'read_project_file', arguments: '{"path":"counter.ts"}' } }],
|
||
}, finish_reason: 'tool_calls' }] }) + '\n\n'))
|
||
.mockResolvedValueOnce(new Response('data: {"choices":[{"delta":{"content":"计数器从 42 开始。"},"finish_reason":"stop"}]}\n\n'));
|
||
try {
|
||
const topic = await f.service.create(f.scope);
|
||
await f.service.send(f.scope, topic.id, { requestId: '22222222-2222-4222-8222-222222222222', text: '解释项目里的计数器' });
|
||
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
|
||
expect(fetch).toHaveBeenCalledTimes(2);
|
||
const sent = JSON.parse(String(fetch.mock.calls[1][1]?.body));
|
||
expect(JSON.stringify(sent.messages)).toContain('创建计数器');
|
||
expect(sent.messages.at(-1).content).toContain('let count = 42;');
|
||
expect((await f.service.read(f.scope, topic.id)).requests[0].response).toBe('计数器从 42 开始。');
|
||
} finally {
|
||
vi.restoreAllMocks();
|
||
}
|
||
});
|
||
it('retains excerpts of the current question and long project review in the model request', async () => {
|
||
const f = await fixture({ sourceContext: {
|
||
...context,
|
||
messages: [
|
||
{ id: 'current-question', role: 'user', text: '请分析小鸟游戏的设计' },
|
||
{ id: 'current-review', role: 'assistant', text: '资源路径问题。'.repeat(1800) + '最后建议使用时间步长。' },
|
||
],
|
||
} });
|
||
const topic = await f.service.create(f.scope);
|
||
const result = await f.service.send(f.scope, topic.id, {
|
||
requestId: '22222222-2222-4222-8222-222222222222', text: '这个设计有什么问题?',
|
||
});
|
||
const sent = JSON.stringify(f.run.mock.calls[0][0]);
|
||
expect(sent).toContain('请分析小鸟游戏的设计');
|
||
expect(sent).toContain('资源路径问题');
|
||
expect(sent).toContain('最后建议使用时间步长');
|
||
expect(result.requests[0].includedSourceMessageIds).toEqual(['current-question', 'current-review']);
|
||
f.finish();
|
||
});
|
||
|
||
it('persists a fixed version, deduplicates requests, and does not change coding metadata', async () => {
|
||
const f = await fixture();
|
||
const before = await readFile(
|
||
path.join(f.created.project.path, '.makelore/conversations.json'),
|
||
'utf8'
|
||
);
|
||
const topic = await f.service.create(f.scope);
|
||
f.nextVersion();
|
||
const next = await f.service.create(f.scope);
|
||
expect(next.version).toBe(2);
|
||
expect(topic.version).toBe(1);
|
||
const input = { requestId: '22222222-2222-4222-8222-222222222222', text: '解释一下' };
|
||
await f.service.send(f.scope, topic.id, input);
|
||
await f.service.send(f.scope, topic.id, input);
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
await expect(
|
||
f.service.send(f.scope, topic.id, { ...input, text: '不同问题' })
|
||
).rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||
f.finish();
|
||
await vi.waitFor(async () =>
|
||
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed')
|
||
);
|
||
expect(
|
||
await readFile(path.join(f.created.project.path, '.makelore/conversations.json'), 'utf8')
|
||
).toBe(before);
|
||
expect((await f.service.list(f.scope)).items).toHaveLength(2);
|
||
});
|
||
it('honors disable for old topics while keeping readable history', async () => {
|
||
const f = await fixture(),
|
||
topic = await f.service.create(f.scope);
|
||
f.disable();
|
||
await expect(
|
||
f.service.send(f.scope, topic.id, {
|
||
requestId: '22222222-2222-4222-8222-222222222222',
|
||
text: '问题',
|
||
})
|
||
).rejects.toMatchObject({ code: 'teacher_disabled' });
|
||
expect((await f.service.read(f.scope, topic.id)).version).toBe(1);
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
it('cancels partial replies and rejects another concurrent question', async () => {
|
||
const f = await fixture(),
|
||
topic = await f.service.create(f.scope),
|
||
id = '22222222-2222-4222-8222-222222222222';
|
||
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
|
||
await expect(
|
||
f.service.send(f.scope, topic.id, {
|
||
requestId: '33333333-3333-4333-8333-333333333333',
|
||
text: '另一个问题',
|
||
})
|
||
).rejects.toMatchObject({ code: 'teacher_topic_busy' });
|
||
await f.service.cancel(f.scope, topic.id, id);
|
||
await vi.waitFor(async () =>
|
||
expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({
|
||
status: 'cancelled',
|
||
response: '', unparsedResponse: '计数器保存一个数字。',
|
||
})
|
||
);
|
||
});
|
||
it('retains interrupted requests on restart without submitting again', async () => {
|
||
const f = await fixture(),
|
||
topic = await f.service.create(f.scope),
|
||
id = '22222222-2222-4222-8222-222222222222';
|
||
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
|
||
const store = new TeacherTopicStore(
|
||
path.join(
|
||
f.created.project.path,
|
||
'.makelore/teacher-conversations',
|
||
topic.accountId,
|
||
f.scope.sourceId
|
||
)
|
||
);
|
||
expect((await store.read(topic.id)).requests[0].status).toBe('interrupted');
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
await f.service.cancel(f.scope, topic.id, id);
|
||
});
|
||
it('checks exact preview revision and never reads project context for preview', async () => {
|
||
const f = await fixture(),
|
||
scope = { projectId: 'preview', sourceId: 'preview' };
|
||
await expect(f.service.create(scope, 1, 'sample')).rejects.toMatchObject({
|
||
code: 'teacher_draft_changed',
|
||
});
|
||
const topic = await f.service.create(scope, 2, '示例项目的计数器');
|
||
await f.service.send(scope, topic.id, {
|
||
requestId: '22222222-2222-4222-8222-222222222222',
|
||
text: '怎么改进',
|
||
});
|
||
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('示例项目的计数器');
|
||
expect(JSON.stringify(f.run.mock.calls[0][0])).not.toContain('创建计数器');
|
||
f.finish();
|
||
});
|
||
it('rejects foreign message references before submitting a model request', async () => {
|
||
const f = await fixture(),
|
||
topic = await f.service.create(f.scope);
|
||
await expect(
|
||
f.service.send(f.scope, topic.id, {
|
||
requestId: '22222222-2222-4222-8222-222222222222',
|
||
text: '解释',
|
||
references: [{ kind: 'message', messageId: 'another-source', text: '别的项目' }],
|
||
})
|
||
).rejects.toMatchObject({ code: 'teacher_reference_invalid' });
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
it('cascades source deletion after cancelling its reply, without recreating files', async () => {
|
||
const f = await fixture(),
|
||
topic = await f.service.create(f.scope);
|
||
await f.service.send(f.scope, topic.id, {
|
||
requestId: '22222222-2222-4222-8222-222222222222',
|
||
text: '问题',
|
||
});
|
||
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||
await expect(f.service.read(f.scope, topic.id)).rejects.toMatchObject({
|
||
code: 'teacher_source_not_found',
|
||
});
|
||
await expect(
|
||
readFile(
|
||
path.join(
|
||
f.created.project.path,
|
||
'.makelore/teacher-conversations',
|
||
topic.accountId,
|
||
f.scope.sourceId,
|
||
topic.id + '.json'
|
||
)
|
||
)
|
||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||
});
|
||
it('fails account changes before dispatch', async () => {
|
||
const f = await fixture(),
|
||
topic = await f.service.create(f.scope);
|
||
f.switchAccount();
|
||
await f.service.send(f.scope, topic.id, {
|
||
requestId: '22222222-2222-4222-8222-222222222222',
|
||
text: '问题',
|
||
});
|
||
await vi.waitFor(async () =>
|
||
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('failed')
|
||
);
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
describe('project teacher and friend consultations', () => {
|
||
const requestId = '22222222-2222-4222-8222-222222222222';
|
||
const nextRequestId = '33333333-3333-4333-8333-333333333333';
|
||
const roles: Array<LegacyConsultationRole | undefined> = [undefined, 'teacher'];
|
||
|
||
it('preserves old synthetic friend history read-only and creates only published agents', async () => {
|
||
const f = await fixture();
|
||
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
|
||
const legacyScope = { ...scope, role: 'friend' as const };
|
||
const current = await f.service.create(scope);
|
||
const legacy = { ...current, id: '44444444-4444-4444-8444-444444444444', role: 'friend' as const,
|
||
definition: { ...current.definition, name: '小麦', teacher_id: 'coding-friend', system_prompt: '旧版本地人设' } };
|
||
const store = new TeacherTopicStore(path.join(f.created.project.path, '.makelore/friend-conversations', current.accountId, 'project'));
|
||
await store.save(legacy);
|
||
expect((await f.service.list(scope)).items).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ id: legacy.id, legacyRole: 'friend' }), expect.objectContaining({ id: current.id }),
|
||
]));
|
||
expect(await f.service.read(legacyScope, legacy.id)).toMatchObject({ role: 'friend', definition: legacy.definition });
|
||
await expect(f.service.create(legacyScope)).rejects.toMatchObject({ code: 'consultation_legacy_read_only' });
|
||
await expect(f.service.send(legacyScope, legacy.id, { requestId, text: '继续' })).rejects.toMatchObject({ code: 'consultation_legacy_read_only' });
|
||
expect(current.role).toBeUndefined();
|
||
expect(current.definition.system_prompt).toBe(definition.system_prompt);
|
||
expect(current.definition.skills).toEqual(definition.skills);
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('selects the current operation conversation per request and deduplicates retries by source', async () => {
|
||
const f = await fixture();
|
||
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
|
||
const other = await f.projects.conversationStore(f.created.project.path).create({
|
||
agentId: f.created.config.defaultAgentId!, title: '另一个操作对话', model: null, modelResolution: 'required',
|
||
});
|
||
f.readSource.mockImplementation(async (selected) => ({
|
||
...context,
|
||
messages: [{ id: selected.sourceId, role: 'user', text: selected.sourceId === f.scope.sourceId ? '第一段操作' : '第二段操作' }],
|
||
}));
|
||
const topic = await f.service.create(scope);
|
||
const input = { requestId, text: '我这样理解对吗?', sourceConversationId: f.scope.sourceId };
|
||
await Promise.all([f.service.send(scope, topic.id, input), f.service.send(scope, topic.id, input)]);
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
expect(f.readSource).toHaveBeenCalledWith({ ...scope, sourceId: f.scope.sourceId });
|
||
await expect(f.service.send(scope, topic.id, { ...input, sourceConversationId: other.id }))
|
||
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||
let complete!: () => void;
|
||
const completed = new Promise<void>((resolve) => { complete = resolve; });
|
||
const unsubscribe = await f.service.subscribe(scope, topic.id, (snapshot) => {
|
||
if (snapshot.requests[0].status === 'completed') complete();
|
||
});
|
||
f.finish();
|
||
await completed;
|
||
unsubscribe();
|
||
await f.service.send(scope, topic.id, { ...input, requestId: nextRequestId, sourceConversationId: other.id });
|
||
const latest = await f.service.read(scope, topic.id);
|
||
expect(latest.requests.map((request) => request.sourceConversationId)).toEqual([f.scope.sourceId, other.id]);
|
||
expect(latest.requests[1].includedSourceMessageIds).toEqual([other.id]);
|
||
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('第二段操作');
|
||
expect(JSON.stringify(f.run.mock.calls[1][0])).not.toContain('第一段操作');
|
||
expect(latest.id).toBe(topic.id);
|
||
});
|
||
|
||
it.each(roles)('rejects cross-project sources and isolates %s topics by project and account', async (role) => {
|
||
const f = await fixture();
|
||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
|
||
const topic = await f.service.create(scope);
|
||
const other = await f.projects.createProject({ projectPath: path.join(f.root, 'other-project'), identity: { kind: 'create' } });
|
||
const foreign = await f.projects.conversationStore(other.project.path).create({
|
||
agentId: other.config.defaultAgentId!, title: '外部对话', model: null, modelResolution: 'required',
|
||
});
|
||
await expect(f.service.send(scope, topic.id, { requestId, text: '帮我理解', sourceConversationId: foreign.id }))
|
||
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||
expect(f.readSource).not.toHaveBeenCalled();
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
|
||
const otherScope = { ...scope, projectId: other.project.id };
|
||
expect((await f.service.list(otherScope)).items).toEqual([]);
|
||
await expect(f.service.read(otherScope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
|
||
f.useOtherAccount();
|
||
expect((await f.service.list(scope)).items).toEqual([]);
|
||
await expect(f.service.read(scope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each(roles)('cancels an active %s reply when its operation source is deleted, preserving project history', async (role) => {
|
||
const f = await fixture();
|
||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
|
||
const topic = await f.service.create(scope);
|
||
await f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
|
||
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||
const saved = await f.service.read(scope, topic.id);
|
||
expect(saved.requests[0]).toMatchObject({ status: 'cancelled', response: '', unparsedResponse: '计数器保存一个数字。' });
|
||
expect((await f.service.list(scope)).items.map((item) => item.id)).toEqual([topic.id]);
|
||
await expect(f.service.send(scope, topic.id, { requestId: nextRequestId, text: '再问一次', sourceConversationId: f.scope.sourceId }))
|
||
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it.each(roles)('does not dispatch a preparing %s request after its source is deleted', async (role) => {
|
||
const f = await fixture();
|
||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
|
||
const topic = await f.service.create(scope);
|
||
let releasePreparation!: () => void;
|
||
f.prepareModel.mockImplementationOnce(async () => {
|
||
await new Promise<void>((resolve) => { releasePreparation = resolve; });
|
||
return { inputLimit: 8000, run: f.run };
|
||
});
|
||
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
|
||
const rejected = expect(sending).rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||
await vi.waitFor(() => expect(f.prepareModel).toHaveBeenCalledOnce());
|
||
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
|
||
releasePreparation();
|
||
await rejected;
|
||
await deleting;
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
|
||
});
|
||
|
||
it('settles a persisted project request as cancelled if deletion races its initial save', async () => {
|
||
const f = await fixture();
|
||
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
|
||
const topic = await f.service.create(scope);
|
||
let saved!: () => void;
|
||
let releaseSave!: () => void;
|
||
const savedToDisk = new Promise<void>((resolve) => { saved = resolve; });
|
||
const allowSaveReturn = new Promise<void>((resolve) => { releaseSave = resolve; });
|
||
const originalSave = TeacherTopicStore.prototype.save;
|
||
const spy = vi.spyOn(TeacherTopicStore.prototype, 'save').mockImplementation(async function (value) {
|
||
await originalSave.call(this, value);
|
||
if (value.id === topic.id && value.requests[0]?.status === 'preparing') {
|
||
saved();
|
||
await allowSaveReturn;
|
||
}
|
||
});
|
||
try {
|
||
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
|
||
await savedToDisk;
|
||
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
|
||
releaseSave();
|
||
await sending;
|
||
await deleting;
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('cancelled');
|
||
const stored = JSON.parse(await readFile(path.join(
|
||
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
|
||
), 'utf8'));
|
||
expect(stored.requests[0].status).toBe('cancelled');
|
||
} finally {
|
||
releaseSave();
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
|
||
|
||
});
|
||
|
||
describe('teacher contextual discussion entry points', () => {
|
||
const requestId = '22222222-2222-4222-8222-222222222222';
|
||
const nextRequestId = '33333333-3333-4333-8333-333333333333';
|
||
const intro = '我们可以从你刚才想做的计数器聊起。';
|
||
const questions = ['我希望谁来使用这个计数器?', '我怎么知道数字有没有按照想法变化?'];
|
||
const response = JSON.stringify({ intro, questions });
|
||
const newScope = (f: Awaited<ReturnType<typeof fixture>>): TeacherScope => ({
|
||
projectId: f.scope.projectId, sourceId: 'project', role: 'teacher',
|
||
});
|
||
const finishRequest = async (f: Awaited<ReturnType<typeof fixture>>, scope: TeacherScope, id: string) => {
|
||
let complete!: (topic: TeacherTopic) => void;
|
||
const finished = new Promise<TeacherTopic>((resolve) => { complete = resolve; });
|
||
const unsubscribe = await f.service.subscribe(scope, id, (topic) => {
|
||
if (['completed', 'failed', 'cancelled'].includes(topic.requests.at(-1)?.status ?? '')) complete(topic);
|
||
});
|
||
f.finish();
|
||
const topic = await finished;
|
||
unsubscribe();
|
||
return topic;
|
||
};
|
||
|
||
it('validates a completed suggestion response, persists it, and includes it in the next question history', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.replyWith('```json\n' + response + '\n```');
|
||
const accepted = await f.service.send(scope, topic.id, {
|
||
requestId, intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: f.scope.sourceId,
|
||
});
|
||
expect(accepted.requests[0]).toMatchObject({ intent: 'suggestions', status: 'running' });
|
||
expect(accepted.requests[0].suggestedQuestions).toBeUndefined();
|
||
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('创建计数器');
|
||
expect(f.run.mock.calls[0][0]).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
|
||
const completed = await finishRequest(f, scope, topic.id);
|
||
expect(completed.requests[0]).toMatchObject({
|
||
intent: 'suggestions', text: '老师,帮我看看', status: 'completed', response: intro, suggestedQuestions: questions,
|
||
usage: { inputTokens: 20, outputTokens: 10 },
|
||
});
|
||
const disk = JSON.parse(await readFile(path.join(
|
||
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
|
||
), 'utf8'));
|
||
expect(disk.requests[0].suggestedQuestions).toEqual(questions);
|
||
expect(disk.requests[0].response).toBe(intro);
|
||
f.replyWith('你想让谁来使用?');
|
||
await f.service.send(scope, topic.id, { requestId: nextRequestId, text: questions[0] });
|
||
const nextMessages = f.run.mock.calls[1][0];
|
||
const history = nextMessages.filter((message: { role: string }) => message.role === 'assistant');
|
||
expect(history[0].content).toContain(intro);
|
||
for (const question of questions) expect(history[0].content).toContain(question);
|
||
expect(nextMessages.at(-1).content).not.toContain('只返回 JSON 对象');
|
||
expect((await f.service.read(scope, topic.id)).requests[1].intent).toBe('question');
|
||
expect(completed.definition.system_prompt).toBe(definition.system_prompt);
|
||
});
|
||
|
||
it.each([
|
||
['只有正文也可以。', '只有正文也可以。', []],
|
||
[JSON.stringify({ intro, questions: ['【我想聊】先说说数字怎么变。'] }), intro, ['【我想聊】先说说数字怎么变。']],
|
||
])('accepts configured prose or one quick reply without old count rules: %s', async (raw, reply, quickReplies) => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.replyWith(raw as string);
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
|
||
const completed = await finishRequest(f, scope, topic.id);
|
||
expect(completed.requests[0]).toMatchObject({
|
||
status: 'completed', response: reply, suggestedQuestions: quickReplies,
|
||
usage: { inputTokens: 20, outputTokens: 10 },
|
||
});
|
||
});
|
||
|
||
it.each([false, true])('persists prose and exact raw diagnostics instead of excess new shortcuts (cloud=%s)', async (cloudTeacher) => {
|
||
const f = await fixture(cloudTeacher ? { cloudTeacher: true, mockCloud: true } : {});
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
const reply = '我们先看看规则会怎样影响结局。';
|
||
const quickReplies = ['看懂规则|现在是什么规则?', '想想影响|摸头会改变什么?', '试试结果|怎样验证?', '换个话题|先聊别的。'];
|
||
const raw = JSON.stringify({ reply, quickReplies }, null, 2);
|
||
f.replyWith(raw);
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '帮我看看' });
|
||
const completed = await finishRequest(f, scope, topic.id);
|
||
expect(completed.requests[0]).toMatchObject({
|
||
status: 'completed', response: reply, suggestedQuestions: [],
|
||
replyParseError: expect.stringContaining('最多 3 条'), unparsedResponse: raw,
|
||
});
|
||
const disk = JSON.parse(await readFile(path.join(
|
||
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
|
||
), 'utf8'));
|
||
expect(disk.requests[0]).toMatchObject({
|
||
response: reply, suggestedQuestions: [], replyParseError: completed.requests[0].replyParseError, unparsedResponse: raw,
|
||
});
|
||
});
|
||
|
||
it('does not treat a cancelled response as valid suggestions even when its JSON is complete', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.replyWith(response);
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
|
||
await f.service.cancel(scope, topic.id, requestId);
|
||
await vi.waitFor(async () => {
|
||
const request = (await f.service.read(scope, topic.id)).requests[0];
|
||
expect(request).toMatchObject({ status: 'cancelled', response: '' });
|
||
expect(request.suggestedQuestions).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
it('retains received bytes when a cancelled runner resolves instead of throwing', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.run.mockImplementationOnce(async (_messages, signal, onText) => {
|
||
onText(response);
|
||
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
|
||
return { inputTokens: 20, outputTokens: 10 };
|
||
});
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '帮我看看' });
|
||
await f.service.cancel(scope, topic.id, requestId);
|
||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0]).toMatchObject({
|
||
status: 'cancelled', response: '', unparsedResponse: response,
|
||
usage: { inputTokens: 20, outputTokens: 10 },
|
||
}));
|
||
expect((await f.service.read(scope, topic.id)).requests[0].suggestedQuestions).toBeUndefined();
|
||
});
|
||
|
||
it('does not parse suggestions after a failed stream or account change', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.run.mockImplementationOnce(async (_messages, _signal, onText) => {
|
||
onText(response);
|
||
throw new TeacherError(502, 'teacher_stream_interrupted', '回复中断,请再试一次。');
|
||
});
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
|
||
await vi.waitFor(async () => {
|
||
const request = (await f.service.read(scope, topic.id)).requests[0];
|
||
expect(request).toMatchObject({ status: 'failed', response: '' });
|
||
expect(request.suggestedQuestions).toBeUndefined();
|
||
});
|
||
const next = await f.service.create(scope);
|
||
f.replyWith(response);
|
||
await f.service.send(scope, next.id, { requestId: nextRequestId, intent: 'suggestions', text: '老师,帮我看看' });
|
||
f.switchAccount();
|
||
f.finish();
|
||
await vi.waitFor(async () => {
|
||
const request = (await f.service.read(scope, next.id)).requests[0];
|
||
expect(request).toMatchObject({ status: 'failed', response: '' });
|
||
expect(request.suggestedQuestions).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
it('retains source-deletion cancellation for suggestions', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.replyWith(response);
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: f.scope.sourceId });
|
||
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||
const saved = await f.service.read(scope, topic.id);
|
||
expect(saved.requests[0]).toMatchObject({ status: 'cancelled', response: '' });
|
||
expect(saved.requests[0].suggestedQuestions).toBeUndefined();
|
||
});
|
||
|
||
it('marks the guided-help entry without choosing a teaching strategy or changing the system instructions', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
f.replyWith('先想一个人:你希望谁来用你的计数器?');
|
||
await f.service.send(scope, topic.id, { requestId, intent: 'guided-help', text: '我也说不清,你带我看看' });
|
||
const completed = await finishRequest(f, scope, topic.id);
|
||
expect(completed.requests[0]).toMatchObject({
|
||
intent: 'guided-help', status: 'completed', response: '先想一个人:你希望谁来用你的计数器?',
|
||
});
|
||
expect(completed.requests[0].suggestedQuestions).toEqual([]);
|
||
const messages = f.run.mock.calls[0][0];
|
||
expect(messages.at(-1).content).toContain('学生主动求助,表示暂时说不清想问什么');
|
||
expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', [], undefined, 'question', undefined, true).messages[0]);
|
||
});
|
||
|
||
it('deduplicates normalized question intent and rejects reuse with another intent', async () => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
const input = { requestId, text: '老师,帮我看看' };
|
||
await f.service.send(scope, topic.id, input);
|
||
await f.service.send(scope, topic.id, { ...input, intent: 'question' });
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
await expect(f.service.send(scope, topic.id, { ...input, intent: 'suggestions' }))
|
||
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||
const second = await f.service.create(scope);
|
||
const suggestions = { requestId: nextRequestId, intent: 'suggestions' as const, text: '老师,帮我看看' };
|
||
await Promise.all([f.service.send(scope, second.id, suggestions), f.service.send(scope, second.id, suggestions)]);
|
||
expect(f.run).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it.each(['suggestions', 'guided-help'] as const)('rejects %s outside the project teacher before preparing a model', async (intent) => {
|
||
const f = await fixture();
|
||
const previewScope = { projectId: 'preview', sourceId: 'preview' };
|
||
const legacy = await f.service.create(f.scope);
|
||
const preview = await f.service.create(previewScope, 2, '示例项目');
|
||
for (const [scope, topic] of [[f.scope, legacy], [previewScope, preview]] as const) {
|
||
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent }))
|
||
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
|
||
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
|
||
}
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
expect(f.readSource).not.toHaveBeenCalled();
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each(['unknown', '', null, 1, {}])('rejects malformed request intent %j before any model preparation', async (intent) => {
|
||
const f = await fixture();
|
||
const scope = newScope(f);
|
||
const topic = await f.service.create(scope);
|
||
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent: intent as TeacherRequestIntent }))
|
||
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('budgets the entry facts even when no source conversation is available', () => {
|
||
const empty = { ...context, messages: [] };
|
||
const ordinary = compileTeacherContext(definition, empty, [], '老师,帮我看看', []);
|
||
const suggestions = compileTeacherContext(definition, empty, [], '老师,帮我看看', [], 8000, 'suggestions');
|
||
expect(suggestions.messages[0]).toEqual(ordinary.messages[0]);
|
||
expect(suggestions.messages.at(-1)?.content).toContain('学生通过“帮我看看”主动请求帮助');
|
||
expect(suggestions.messages.some(message => message.content.startsWith('来源编程会话'))).toBe(false);
|
||
expect(() => compileTeacherContext(definition, empty, [], '老师,帮我看看', [], estimateTeacherTokens(ordinary.messages), 'suggestions'))
|
||
.toThrow('超过上下文预算');
|
||
});
|
||
|
||
it.each(['suggestions', 'guided-help'] as const)('passes %s entry facts without local teaching or expression policy', (intent) => {
|
||
const selected = { ...definition, system_prompt: '云端配置:正文长度随内容,选项保留【我想聊】前缀。' };
|
||
const compiled = compileTeacherContext(selected, context, [], '帮我看看', [], 8000, intent, teacherReplyInstructions());
|
||
const current = compiled.messages.at(-1)!.content;
|
||
expect(current).toContain(intent === 'suggestions'
|
||
? '学生通过“帮我看看”主动请求帮助' : '学生主动求助,表示暂时说不清想问什么');
|
||
expect(compiled.messages[0].content).toContain(selected.system_prompt);
|
||
const local = compiled.messages.slice(1).map(message => message.content).join('\n');
|
||
for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些',
|
||
'推荐一个具体切入点', '说明为何', '快捷回复围绕', '从构思切入', '整理想法', '理解关系', '承接已确认的共识'])
|
||
expect(local).not.toContain(removed);
|
||
});
|
||
|
||
});
|
||
|
||
describe('project teacher check-ins', () => {
|
||
const requestId = '22222222-2222-4222-8222-222222222222';
|
||
const nextRequestId = '33333333-3333-4333-8333-333333333333';
|
||
const thirdRequestId = '44444444-4444-4444-8444-444444444444';
|
||
const projectScope = (f: Awaited<ReturnType<typeof fixture>>): TeacherScope => ({
|
||
projectId: f.scope.projectId, sourceId: 'project', role: 'teacher',
|
||
});
|
||
const input = (f: Awaited<ReturnType<typeof fixture>>, id = requestId) => ({
|
||
requestId: id, sourceConversationId: f.scope.sourceId,
|
||
});
|
||
const settle = async (f: Awaited<ReturnType<typeof fixture>>, topic: TeacherTopic) => {
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.read(projectScope(f), topic.id)).requests.at(-1)?.status).toBe('completed'));
|
||
return await f.service.read(projectScope(f), topic.id);
|
||
};
|
||
const startClock = () => {
|
||
vi.useFakeTimers({ toFake: ['Date'] });
|
||
vi.setSystemTime(new Date('2026-09-22T12:00:00Z'));
|
||
};
|
||
|
||
it('continues the selected teacher history with a real configured-model call and no fabricated student turn', async () => {
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const selected = await f.service.create(scope);
|
||
await f.service.send(scope, selected.id, { requestId, text: '变量是什么意思?' });
|
||
await settle(f, selected);
|
||
await f.service.create(scope);
|
||
await f.service.read(scope, selected.id);
|
||
f.replyWith('你已经在试着保存数字了。想想这个数字要在什么时候改变?');
|
||
const result = await f.service.checkIn(scope, input(f, nextRequestId));
|
||
expect(result.topic?.id).toBe(selected.id);
|
||
const saved = await settle(f, result.topic!);
|
||
expect(saved.requests.at(-1)).toMatchObject({
|
||
intent: 'check-in', text: '', references: [], sourceConversationId: f.scope.sourceId, status: 'completed',
|
||
});
|
||
expect(saved.requests.at(-1)?.checkInSourceFingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||
const messages = f.run.mock.calls[1][0];
|
||
expect(messages[0].content).toContain('通过问题引导思考');
|
||
expect(messages[0].content).toContain('使用具体的小例子');
|
||
expect(JSON.stringify(messages)).toContain('创建计数器');
|
||
expect(JSON.stringify(messages)).toContain('变量是什么意思');
|
||
expect(messages.at(-1)).toMatchObject({ role: 'system' });
|
||
expect(messages.at(-1).content).toContain('程序触发的项目进展检查,学生没有在本轮主动提问');
|
||
expect(messages.at(-1).content).toContain('回应方式遵循当前智能体的云端配置');
|
||
expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition, expect.objectContaining({
|
||
projectPath: f.created.project.path, source: context, assertCurrent: expect.any(Function),
|
||
}), { finalOnly: false });
|
||
const followup = compileTeacherContext(saved.definition, context, saved.requests, '继续说', []);
|
||
expect(followup.messages.filter((message) => message.role === 'user').map((message) => message.content))
|
||
.toEqual([expect.stringContaining('创建计数器'), expect.stringContaining('变量是什么意思?'), '当前问题:\n继续说']);
|
||
expect(followup.messages.filter((message) => message.role === 'assistant').map((message) => message.content))
|
||
.toContainEqual(expect.stringContaining(saved.requests.at(-1)!.response));
|
||
});
|
||
|
||
it('serializes first-topic creation, returns the same accepted request, and skips a concurrent distinct check-in', async () => {
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const [first, duplicate, competing] = await Promise.all([
|
||
f.service.checkIn(scope, input(f)), f.service.checkIn(scope, input(f)),
|
||
f.service.checkIn(scope, input(f, nextRequestId)),
|
||
]);
|
||
expect(first.topic?.id).toBe(duplicate.topic?.id);
|
||
expect(first.topic?.requests).toHaveLength(1);
|
||
expect(competing).toEqual({ topic: null, skipped: 'busy' });
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
expect((await f.service.list(scope)).items).toHaveLength(1);
|
||
expect((await f.service.list(scope)).items[0].title).toBe('和智能体聊聊');
|
||
await f.service.create(scope);
|
||
expect((await f.service.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
|
||
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
|
||
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||
});
|
||
|
||
it('enforces project-wide cooldown and ignores cursor churn until completed text changes', async () => {
|
||
startClock();
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const first = await f.service.checkIn(scope, input(f));
|
||
await settle(f, first.topic!);
|
||
await f.service.create(scope);
|
||
expect(await f.service.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'cooldown' });
|
||
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
|
||
f.readSource.mockResolvedValue({ ...context, cursor: { workerGeneration: 90, seq: 500 } });
|
||
expect(await f.service.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
f.readSource.mockResolvedValue({ ...context, messages: [...context.messages, { id: 'new', role: 'assistant', text: '添加了重置按钮。' }] });
|
||
const next = await f.service.checkIn(scope, input(f, nextRequestId));
|
||
expect(next.topic?.id).not.toBe(first.topic?.id);
|
||
expect(f.run).toHaveBeenCalledTimes(2);
|
||
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('添加了重置按钮');
|
||
});
|
||
|
||
it('uses persisted history for request deduplication, cooldown, and unchanged-source checks after restart', async () => {
|
||
startClock();
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const first = await f.service.checkIn(scope, input(f));
|
||
await settle(f, first.topic!);
|
||
const restarted = await f.restart();
|
||
expect((await restarted.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
|
||
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'cooldown' });
|
||
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
|
||
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('follows up on unchanged work after fifteen minutes instead of suppressing that source forever', async () => {
|
||
startClock();
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const first = await f.service.checkIn(scope, input(f));
|
||
await settle(f, first.topic!);
|
||
const restarted = await f.restart();
|
||
vi.setSystemTime(Date.parse(first.topic!.requests[0].createdAt) + TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS - 1);
|
||
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
|
||
vi.setSystemTime(Date.now() + 1);
|
||
expect((await restarted.checkIn(scope, input(f, nextRequestId))).topic?.id).toBe(first.topic?.id);
|
||
expect(f.run).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('counts a new teacher discussion as progress even when operation text has not changed', async () => {
|
||
startClock();
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const first = await f.service.checkIn(scope, input(f));
|
||
await settle(f, first.topic!);
|
||
await f.service.send(scope, first.topic!.id, { requestId: nextRequestId, sourceConversationId: f.scope.sourceId, text: '我想让宠物跳起来的时候有个小惊喜' });
|
||
await settle(f, first.topic!);
|
||
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
|
||
expect((await f.service.checkIn(scope, input(f, thirdRequestId))).topic?.id).toBe(first.topic?.id);
|
||
expect(f.run).toHaveBeenCalledTimes(3);
|
||
expect(JSON.stringify(f.run.mock.calls[2][0])).toContain('小惊喜');
|
||
});
|
||
|
||
it('can follow an existing teacher discussion before the first completed operation message', async () => {
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const topic = await f.service.create(scope);
|
||
await f.service.send(scope, topic.id, { requestId, sourceConversationId: f.scope.sourceId, text: '我想做一个养宠物的游戏' });
|
||
await settle(f, topic);
|
||
f.readSource.mockResolvedValue({ ...context, messages: [] });
|
||
expect((await f.service.checkIn(scope, input(f, nextRequestId))).topic?.id).toBe(topic.id);
|
||
expect(f.run).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('does not create a topic or call a model without completed source text', async () => {
|
||
const f = await fixture();
|
||
f.readSource.mockResolvedValue({ ...context, messages: [] });
|
||
expect(await f.service.checkIn(projectScope(f), input(f))).toEqual({ topic: null, skipped: 'no-context' });
|
||
expect((await f.service.list(projectScope(f))).items).toEqual([]);
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('skips archived or disabled sources and rejects sources outside the project before reading context', async () => {
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
|
||
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||
const conversations = f.projects.conversationStore(f.created.project.path);
|
||
await conversations.patchMetadata(f.scope.sourceId, { archivedAt: new Date().toISOString() });
|
||
expect(await f.service.checkIn(scope, input(f))).toEqual({ topic: null, skipped: 'archived' });
|
||
await conversations.patchMetadata(f.scope.sourceId, { archivedAt: null });
|
||
f.disable();
|
||
expect(await f.service.checkIn(scope, input(f))).toEqual({ topic: null, skipped: 'disabled' });
|
||
expect(f.readSource).not.toHaveBeenCalled();
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects friend, preview, legacy-source scopes and ordinary messages that try to bypass check-in guards', async () => {
|
||
const f = await fixture();
|
||
for (const scope of [{ projectId: 'preview', sourceId: 'preview' }, f.scope]) {
|
||
await expect(f.service.checkIn(scope, input(f))).rejects.toMatchObject({ code: 'teacher_intent_invalid' });
|
||
}
|
||
const scope = projectScope(f);
|
||
const topic = await f.service.create(scope);
|
||
await expect(f.service.send(scope, topic.id, { ...input(f), intent: 'check-in', text: '' }))
|
||
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
|
||
expect(f.readSource).not.toHaveBeenCalled();
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('waits for manual request preparation and skips while any teacher topic is answering', async () => {
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const answering = await f.service.create(scope);
|
||
await f.service.create(scope);
|
||
let releaseSource!: (source: TeacherSourceContext) => void;
|
||
f.readSource.mockImplementationOnce(() => new Promise((resolve) => { releaseSource = resolve; }));
|
||
const manual = f.service.send(scope, answering.id, { requestId, text: '解释一下', sourceConversationId: f.scope.sourceId });
|
||
await vi.waitFor(() => expect(f.readSource).toHaveBeenCalledOnce());
|
||
const check = f.service.checkIn(scope, input(f, nextRequestId));
|
||
releaseSource(context);
|
||
await manual;
|
||
expect(await check).toEqual({ topic: null, skipped: 'busy' });
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('does not persist or run a check-in when the account changes during preparation', async () => {
|
||
const f = await fixture();
|
||
f.prepareModel.mockImplementationOnce(async () => {
|
||
f.switchAccount();
|
||
return { inputLimit: 8000, run: f.run };
|
||
});
|
||
await expect(f.service.checkIn(projectScope(f), input(f))).rejects.toMatchObject({ code: 'teacher_account_changed' });
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
const list = await f.service.list(projectScope(f));
|
||
expect((await f.service.read(projectScope(f), list.items[0].id)).requests).toEqual([]);
|
||
});
|
||
|
||
it('cancels an accepted check-in when its source is deleted and never restarts it', async () => {
|
||
const f = await fixture();
|
||
const scope = projectScope(f);
|
||
const first = await f.service.checkIn(scope, input(f));
|
||
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||
expect((await f.service.read(scope, first.topic!.id)).requests[0].status).toBe('cancelled');
|
||
expect((await f.service.checkIn(scope, input(f))).topic?.requests[0].status).toBe('cancelled');
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
});
|
||
});
|
||
|
||
it('accepts teacher check-ins through the project endpoint and returns skip results without a model call', async () => {
|
||
const f = await fixture();
|
||
const server = createServer((req, res) => {
|
||
void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'), {
|
||
codingProducts: { teacher: f.service },
|
||
} as HostApiContext);
|
||
});
|
||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const address = server.address();
|
||
if (!address || typeof address === 'string') throw new Error('no address');
|
||
const url = `http://127.0.0.1:${address.port}/api/coding/projects/${f.scope.projectId}/teacher-check-in`;
|
||
const input = { requestId: '22222222-2222-4222-8222-222222222222', sourceConversationId: f.scope.sourceId };
|
||
const post = () => fetch(url, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
||
});
|
||
try {
|
||
expect((await fetch(url)).status).toBe(405);
|
||
f.readSource.mockResolvedValueOnce({ ...context, messages: [] });
|
||
const skipped = await post();
|
||
expect(skipped.status).toBe(200);
|
||
expect(await skipped.json()).toEqual({ topic: null, skipped: 'no-context' });
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
const accepted = await post();
|
||
expect(accepted.status).toBe(200);
|
||
const result = await accepted.json();
|
||
expect(result.topic.requests[0]).toMatchObject({ intent: 'check-in', text: '', status: 'running' });
|
||
expect((await (await post()).json()).topic.id).toBe(result.topic.id);
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
} finally {
|
||
server.closeAllConnections();
|
||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||
}
|
||
});
|
||
|
||
describe('teacher context and wire contract', () => {
|
||
it('takes only complete user/assistant text and preserves the read cursor', () => {
|
||
const snapshot = {
|
||
nodes: [
|
||
{
|
||
kind: 'message',
|
||
id: 'u',
|
||
role: 'user',
|
||
status: 'complete',
|
||
blocks: [{ kind: 'text', status: 'complete', text: '用户问题' }],
|
||
},
|
||
{
|
||
kind: 'message',
|
||
id: 'a',
|
||
role: 'assistant',
|
||
status: 'complete',
|
||
blocks: [
|
||
{ kind: 'thinking', status: 'complete', text: 'private' },
|
||
{ kind: 'text', status: 'complete', text: '完整回答' },
|
||
],
|
||
},
|
||
{
|
||
kind: 'message',
|
||
id: 'live',
|
||
role: 'assistant',
|
||
status: 'streaming',
|
||
blocks: [{ kind: 'text', status: 'streaming', text: '未完成' }],
|
||
},
|
||
{ kind: 'tool', id: 'tool', args: { password: 'secret' } },
|
||
],
|
||
cursor: { workerGeneration: 2, seq: 10 },
|
||
} as ConversationSnapshot;
|
||
const selected = sourceContext(snapshot);
|
||
expect(selected.messages.map((message) => message.text)).toEqual(['用户问题', '完整回答']);
|
||
expect(selected.cursor).toEqual(snapshot.cursor);
|
||
});
|
||
it('trims old source messages but retains instructions, Skill, explicit quote and question', () => {
|
||
const references = [{ kind: 'code' as const, text: 'count += 1' }];
|
||
const budget = estimateTeacherTokens(compileTeacherContext(
|
||
definition, context, [], '为什么这样?', references
|
||
).messages);
|
||
const compiled = compileTeacherContext(
|
||
definition,
|
||
{
|
||
...context,
|
||
messages: [{ id: 'old', role: 'user', text: 'old'.repeat(4000) }, ...context.messages],
|
||
},
|
||
[],
|
||
'为什么这样?',
|
||
references,
|
||
budget
|
||
);
|
||
const text = JSON.stringify(compiled.messages);
|
||
expect(text).toContain('通过问题引导思考');
|
||
expect(text).toContain('使用具体的小例子');
|
||
expect(text).toContain('count += 1');
|
||
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||
expect(compiled.includedSourceMessageIds).toEqual(['source-user']);
|
||
expect(compiled.omittedMessages).toBe(1);
|
||
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(18000), [])).toThrow(
|
||
'超过上下文预算'
|
||
);
|
||
});
|
||
it('keeps a long previous teacher answer available for a follow-up within the read budget', () => {
|
||
const history: TeacherRequest[] = [{
|
||
id: 'previous', text: '帮我分析', response: '重力的问题。'.repeat(1800) + '建议使用时间步长。',
|
||
references: [], createdAt: 'now', sourceCursor: context.cursor, sourceCapturedAt: 'now',
|
||
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed',
|
||
}];
|
||
const question = '你刚才的建议是什么意思?';
|
||
const fixed = compileTeacherContext(definition, context, [], question, [], undefined, 'question', undefined, true);
|
||
const budget = estimateTeacherTokens(fixed.messages) + 1500;
|
||
const compiled = compileTeacherContext(definition, context, history, question, [], budget, 'question', undefined, true);
|
||
expect(compiled.truncatedMessages).toBeGreaterThan(0);
|
||
expect(estimateTeacherTokens(compiled.messages)).toBeLessThanOrEqual(budget);
|
||
expect(JSON.stringify(compiled.messages)).toContain('创建计数器');
|
||
expect(JSON.stringify(compiled.messages)).toContain('重力的问题');
|
||
expect(JSON.stringify(compiled.messages)).toContain('建议使用时间步长');
|
||
});
|
||
it('sends no tools, ignores reasoning deltas, and requires a terminal stream', async () => {
|
||
const text: string[] = [];
|
||
const fake = vi.fn(
|
||
async () =>
|
||
new Response(
|
||
'data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"答案"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\ndata: [DONE]\n\n'
|
||
)
|
||
);
|
||
const usage = await streamTeacherReply(
|
||
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
|
||
'model',
|
||
{},
|
||
1500,
|
||
[{ role: 'user', content: '问题' }],
|
||
new AbortController().signal,
|
||
(delta) => text.push(delta),
|
||
fake
|
||
);
|
||
expect(text).toEqual(['答案']);
|
||
expect(usage).toEqual({ inputTokens: 10, outputTokens: 3 });
|
||
const input = fake.mock.calls[0] as unknown as [string, RequestInit];
|
||
const body = JSON.parse(input[1].body as string);
|
||
expect(body).not.toHaveProperty('tools');
|
||
expect(input[0]).toBe('https://gateway.test/v1/chat/completions');
|
||
await expect(
|
||
streamTeacherReply(
|
||
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
|
||
'model',
|
||
{},
|
||
1500,
|
||
[],
|
||
new AbortController().signal,
|
||
() => undefined,
|
||
async () => new Response('data: {"choices":[{"delta":{"content":"半句"}}]}\n\n')
|
||
)
|
||
).rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
|
||
});
|
||
it('accepts only the exact credential-free preview link', () => {
|
||
expect(
|
||
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2')
|
||
).toMatchObject({ type: 'teacher-preview', draftRevision: 2 });
|
||
expect(
|
||
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2&token=x')
|
||
).toBeNull();
|
||
expect(
|
||
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=0')
|
||
).toBeNull();
|
||
});
|
||
it('creates one default and preserves all legacy identities without reviving disabled Agents', () => {
|
||
const base = createCodingProjectConfigV2();
|
||
expect(base.agents).toHaveLength(1);
|
||
expect(ensureDefaultCodingAgent(base)).toBe(base);
|
||
const legacy = {
|
||
...base,
|
||
defaultAgentId: undefined,
|
||
agents: [
|
||
{ ...base.agents[0], id: 'old', enabled: false },
|
||
{ ...base.agents[0], id: 'existing', pinned: true },
|
||
],
|
||
};
|
||
const normalized = ensureDefaultCodingAgent(legacy);
|
||
expect(normalized.defaultAgentId).toBe('existing');
|
||
expect(normalized.agents).toEqual(legacy.agents);
|
||
const disabled = ensureDefaultCodingAgent({ ...legacy, agents: [legacy.agents[0]] });
|
||
expect(disabled.agents[0].enabled).toBe(false);
|
||
expect(disabled.agents).toHaveLength(2);
|
||
});
|
||
});
|
||
import {mkdir, writeFile} from 'node:fs/promises';
|
||
import {readCodingConversationHistory} from '../../electron/coding-projects/conversation-history';
|
||
import {getPiManagedPaths} from '../../electron/coding-runtime/pi/resource-loader';
|
||
it('reads only the durable active branch without starting a worker', async()=>{
|
||
const f=await fixture();
|
||
const metadata=(await f.projects.conversationStore(f.created.project.path).get(f.scope.sourceId))!;
|
||
const folder=path.join(getPiManagedPaths(f.root).sessionsDir,f.scope.projectId);await mkdir(folder,{recursive:true});
|
||
const entries=[
|
||
{type:'session',id:'session'},
|
||
{type:'message',id:'root',parentId:null,message:{role:'user',content:'开始学习'}},
|
||
{type:'message',id:'abandoned',parentId:'root',message:{role:'user',content:'旧分支内容'}},
|
||
{type:'message',id:'active',parentId:'root',message:{role:'user',content:'当前分支内容'}},
|
||
];
|
||
await writeFile(path.join(folder,'session-test.jsonl'),entries.map(entry=>JSON.stringify(entry)).join('\n'));
|
||
const snapshot=await readCodingConversationHistory(f.root,f.scope.projectId,{...metadata,sessionKey:'session-test'});
|
||
expect(sourceContext(snapshot).messages.map(message=>message.text)).toEqual(['开始学习','当前分支内容']);
|
||
expect(snapshot.worker.status).toBe('stopped');
|
||
});
|
||
it('sends durable source history through the real teacher service without a running worker', async () => {
|
||
const f = await fixture({ durableSource: true });
|
||
await f.projects.conversationStore(f.created.project.path).ensureSessionBinding(f.scope.sourceId,
|
||
async () => ({ sessionKey: 'teacher-source', piSessionId: 'teacher-source' }));
|
||
const folder = path.join(getPiManagedPaths(f.root).sessionsDir, f.scope.projectId);
|
||
await mkdir(folder, { recursive: true });
|
||
await writeFile(path.join(folder, 'teacher-source.jsonl'), [
|
||
{ type: 'session', id: 'teacher-source' },
|
||
{ type: 'message', id: 'root', parentId: null, message: { role: 'user', content: '分析小鸟游戏' } },
|
||
{ type: 'message', id: 'other', parentId: 'root', message: { role: 'user', content: '废弃的分支' } },
|
||
{ type: 'message', id: 'active', parentId: 'root', message: { role: 'assistant', content: [{ type: 'text', text: '检查碰撞检测' }] } },
|
||
].map(entry => JSON.stringify(entry)).join('\n'));
|
||
const topic = await f.service.create(f.scope);
|
||
await f.service.send(f.scope, topic.id, { requestId: '22222222-2222-4222-8222-222222222222', text: '老师怎么看?' });
|
||
const sent = JSON.stringify(f.run.mock.calls[0][0]);
|
||
expect(sent).toContain('分析小鸟游戏');
|
||
expect(sent).toContain('检查碰撞检测');
|
||
expect(sent).not.toContain('废弃的分支');
|
||
f.finish();
|
||
});
|
||
import {createServer} from 'node:http';
|
||
import {handleCodingTeacherRoutes} from '../../electron/api/routes/coding-teacher';
|
||
import type {HostApiContext} from '../../electron/api/context';
|
||
it('serves topic acceptance and SSE snapshots without cancelling on stream close',async()=>{
|
||
const f=await fixture();
|
||
const server=createServer((req,res)=>{void handleCodingTeacherRoutes(req,res,new URL(req.url!,'http://localhost'),{codingProducts:{teacher:f.service}} as HostApiContext);});
|
||
await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||
const address=server.address();if(!address||typeof address==='string')throw new Error('no address');
|
||
const origin='http://127.0.0.1:'+address.port;
|
||
const base=origin+'/api/coding/projects/'+f.scope.projectId+'/conversations/'+f.scope.sourceId+'/teacher-topics';
|
||
try {
|
||
const created=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});expect(created.status).toBe(201);
|
||
const topic=await created.json();const requestId='22222222-2222-4222-8222-222222222222';
|
||
const accepted=await fetch(base+'/'+topic.id+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requestId,text:'讲解一下'})});expect(accepted.status).toBe(202);
|
||
const stream=await fetch(base+'/'+topic.id+'/events');expect(stream.headers.get('content-type')).toContain('text/event-stream');
|
||
const reader=stream.body!.getReader();const chunk=await reader.read();expect(new TextDecoder().decode(chunk.value)).toContain('event: snapshot');await reader.cancel();
|
||
expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('running');
|
||
const cancelled=await fetch(base+'/'+topic.id+'/requests/'+requestId+'/cancel',{method:'POST'});expect(cancelled.status).toBe(200);
|
||
await vi.waitFor(async()=>expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('cancelled'));
|
||
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
|
||
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
|
||
});
|
||
|
||
it.each(['agent', 'teacher'] as const)('routes project-level %s config and topic messages without a source-scoped URL', async (role) => {
|
||
const f = await fixture();
|
||
const server = createServer((req, res) => {
|
||
void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'), {
|
||
codingProducts: { teacher: f.service },
|
||
} as HostApiContext);
|
||
});
|
||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const address = server.address();
|
||
if (!address || typeof address === 'string') throw new Error('no address');
|
||
const origin = 'http://127.0.0.1:' + address.port;
|
||
const base = `${origin}/api/coding/projects/${f.scope.projectId}/${role}-topics`;
|
||
const post = (url: string, body = {}) => fetch(url, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||
});
|
||
try {
|
||
const config = await fetch(`${origin}/api/coding/teacher/config`);
|
||
expect(config.status).toBe(200);
|
||
expect((await config.json()).definition.teacher_id).toBe('coding-teacher');
|
||
expect((await post(`${origin}/api/coding/teacher/config`)).status).toBe(405);
|
||
const created = await post(base);
|
||
expect(created.status).toBe(201);
|
||
const topic = await created.json();
|
||
expect(topic).toMatchObject({ sourceConversationId: 'project', projectId: f.scope.projectId });
|
||
const listed = await fetch(base);
|
||
expect((await listed.json()).items.map((item: { id: string }) => item.id)).toEqual([topic.id]);
|
||
expect((await fetch(`${base}/${topic.id}`)).status).toBe(200);
|
||
expect(f.run).not.toHaveBeenCalled();
|
||
const requestId = '22222222-2222-4222-8222-222222222222';
|
||
const input = { requestId, text: '我想聊聊', sourceConversationId: f.scope.sourceId };
|
||
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
|
||
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
const accepted = await fetch(`${base}/${topic.id}`);
|
||
expect((await accepted.json()).requests[0].sourceConversationId).toBe(f.scope.sourceId);
|
||
expect((await post(`${base}/${topic.id}/requests/${requestId}/cancel`)).status).toBe(200);
|
||
await vi.waitFor(async () => expect((await f.service.read({ projectId: f.scope.projectId, sourceId: 'project' }, topic.id)).requests[0].status).toBe('cancelled'));
|
||
} finally {
|
||
server.closeAllConnections();
|
||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||
}
|
||
});
|
||
|
||
|
||
describe('structured teacher service integration', () => {
|
||
it.each(['discussion-v1', undefined] as const)('rotates a legacy %s cloud checkpoint within the same visible conversation', async (presentation) => {
|
||
const f = await fixture({ cloudTeacher: true, mockCloud: true });
|
||
const agentId = 'cloud-teacher';
|
||
const input = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId,
|
||
requestId: crypto.randomUUID(), text: '看看数字如何改变' };
|
||
const reply = '已确认的旧正文:数字会随着玩家操作改变。';
|
||
const quickReplies = ['看看改变的时机'];
|
||
f.replyWith(JSON.stringify({ reply, quickReplies }));
|
||
await f.service.sendConversation(agentId, input);
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.conversation(agentId)).topic?.requests[0].status).toBe('completed'));
|
||
const original = (await f.service.conversation(agentId)).topic!;
|
||
await f.service.dispose();
|
||
|
||
const turnFile = path.join(f.root, 'agent-conversations', original.accountId, agentId, 'turns', input.requestId + '.json');
|
||
const archived = { ...original.requests[0], presentation,
|
||
discussionContext: { focusId: 'RETIRED_CHECKPOINT_CONTEXT' },
|
||
discussionSnapshot: { kind: 'ideas', title: 'RETIRED_CHECKPOINT_COMPONENT' } };
|
||
await writeFile(turnFile, JSON.stringify(archived));
|
||
const service = await f.restart();
|
||
await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '继续看看' });
|
||
const migrated = (await service.conversation(agentId)).topic!;
|
||
expect(migrated.id).toBe(original.id);
|
||
expect(migrated.conversation?.segmentTurns).toBe(1);
|
||
expect(migrated.requests[1]).toMatchObject({ projectId: input.projectId,
|
||
sourceConversationId: input.sourceConversationId, teacherVersion: 9, presentation: 'reply-v1' });
|
||
expect(migrated.requests[1].runtimeThreadId).toBeTruthy();
|
||
expect(migrated.requests[1].runtimeThreadId).not.toBe(original.requests[0].runtimeThreadId);
|
||
const bootstrap = JSON.stringify(f.run.mock.calls[1][0]);
|
||
expect(bootstrap).toContain(reply);
|
||
expect(bootstrap).toContain(quickReplies[0]);
|
||
expect(bootstrap).not.toContain('RETIRED_CHECKPOINT_');
|
||
expect(f.run.mock.calls[1][0]).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await service.conversation(agentId)).topic?.requests[1].status).toBe('completed'));
|
||
|
||
await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '再往下聊' });
|
||
const continued = (await service.conversation(agentId)).topic!;
|
||
expect(continued.id).toBe(original.id);
|
||
expect(continued.conversation?.segmentTurns).toBe(2);
|
||
expect(continued.requests[2].runtimeThreadId).toBe(migrated.requests[1].runtimeThreadId);
|
||
expect(JSON.stringify(f.run.mock.calls[2][0])).not.toContain(reply);
|
||
expect(continued.requests[0]).toEqual(archived);
|
||
expect(JSON.parse(await readFile(turnFile, 'utf8'))).toEqual(JSON.parse(JSON.stringify(archived)));
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await service.conversation(agentId)).topic?.requests[2].status).toBe('completed'));
|
||
expect((await service.conversation(agentId)).topic?.requests[2].usage).toEqual({ inputTokens: 20, outputTokens: 10 });
|
||
});
|
||
|
||
it('keeps continuous chat scopes, releases and usage while archived components remain inert', async () => {
|
||
const f = await fixture({ cloudTeacher: true, mockCloud: true });
|
||
const agentId = 'cloud-teacher';
|
||
const firstInput = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId,
|
||
requestId: crypto.randomUUID(), text: '接着看看数字改变的时机' };
|
||
const reply = '先看数字改变前后,玩家能收到什么反馈。';
|
||
const quickReplies = ['【想试试】先观察数字变化', '【还不确定】说说我期待的反馈'];
|
||
f.replyWith(JSON.stringify({ reply, quickReplies, tool: { kind: 'flow', title: 'DISCARDED_COMPONENT_OUTPUT' } }));
|
||
await f.service.sendConversation(agentId, firstInput);
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.conversation(agentId)).topic?.requests[0].status).toBe('completed'));
|
||
const first = (await f.service.conversation(agentId)).topic!;
|
||
expect(first.requests[0]).toMatchObject({ id: firstInput.requestId, text: firstInput.text,
|
||
projectId: firstInput.projectId, sourceConversationId: firstInput.sourceConversationId,
|
||
projectName: f.created.project.name, teacherVersion: 9, presentation: 'reply-v1', response: reply,
|
||
suggestedQuestions: quickReplies, usage: { inputTokens: 20, outputTokens: 10 } });
|
||
expect(first.requests[0].runtimeThreadId).toBeTruthy();
|
||
expect(first.requests[0]).not.toHaveProperty('discussionSnapshot');
|
||
await f.service.dispose();
|
||
|
||
// Simulate a continuous conversation saved by the pre-cleanup release. Its
|
||
// top-level and per-project component archives must not overwrite each other.
|
||
const directory = path.join(f.root, 'agent-conversations', first.accountId, agentId);
|
||
const manifestFile = path.join(directory, 'conversation.json');
|
||
const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
|
||
const legacy = { id: 'old-component', revision: -1, status: 'active', content: { kind: 'ideas', title: 'ARCHIVED_COMPONENT_TOPIC' } };
|
||
const projectsArchive = { [f.scope.projectId]: { content: 'ARCHIVED_COMPONENT_PROJECT', previousIdeas: ['unchanged'] } };
|
||
manifest.topic.discussion = legacy;
|
||
manifest.topic.conversation.discussions = projectsArchive;
|
||
await writeFile(manifestFile, JSON.stringify(manifest));
|
||
const turnFile = path.join(directory, 'turns', firstInput.requestId + '.json');
|
||
const oldTurn = { ...first.requests[0], discussionContext: { focusId: 'ARCHIVED_COMPONENT_FOCUS' },
|
||
discussionSnapshot: { kind: 'unknown', data: 'ARCHIVED_COMPONENT_SNAPSHOT' },
|
||
discussionError: 'ARCHIVED_COMPONENT_ERROR', unparsedResponse: 'ARCHIVED_COMPONENT_RAW' };
|
||
await writeFile(turnFile, JSON.stringify(oldTurn));
|
||
const service = await f.restart();
|
||
const secondInput = { ...firstInput, requestId: crypto.randomUUID(), text: quickReplies[0] };
|
||
await service.sendConversation(agentId, secondInput);
|
||
const sameSegmentMessages = f.run.mock.calls[1][0];
|
||
expect(sameSegmentMessages).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
|
||
expect(JSON.stringify(sameSegmentMessages)).not.toContain(reply);
|
||
const access = f.prepareCloud.mock.calls[1][3] as TeacherReadAccess;
|
||
const tools = createTeacherReadTools(access);
|
||
const historicalText = await tools.execute('read_conversation',
|
||
JSON.stringify({ message_id: 'teacher:' + firstInput.requestId + ':assistant' }), new AbortController().signal);
|
||
expect(historicalText).toContain(reply);
|
||
expect(historicalText).toContain(quickReplies[0]);
|
||
expect(historicalText).not.toContain('ARCHIVED_COMPONENT_');
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await service.conversation(agentId)).topic?.requests.at(-1)?.status).toBe('completed'));
|
||
const second = (await service.conversation(agentId)).topic!;
|
||
expect(second.requests[1].runtimeThreadId).toBe(first.requests[0].runtimeThreadId);
|
||
|
||
const other = await f.projects.createProject({ projectPath: path.join(f.root, 'another-project'), identity: { kind: 'create' } });
|
||
const otherSource = await f.projects.conversationStore(other.project.path).create({
|
||
agentId: other.config.defaultAgentId!, title: '另一个来源', model: null, modelResolution: 'required',
|
||
});
|
||
f.publishCloudVersion(10);
|
||
const nextInput = { projectId: other.project.id, sourceConversationId: otherSource.id,
|
||
requestId: crypto.randomUUID(), text: '在新项目里继续聊' };
|
||
await service.sendConversation(agentId, nextInput);
|
||
await service.sendConversation(agentId, { ...nextInput,
|
||
presentation: 'discussion-v1', discussion: { focusId: 'IGNORED_CLIENT_COMPONENT' } } as typeof nextInput);
|
||
expect(f.run).toHaveBeenCalledTimes(3);
|
||
const carried = JSON.stringify(f.run.mock.calls[2][0]);
|
||
expect(carried).toContain(reply);
|
||
expect(carried).toContain(f.created.project.name);
|
||
expect(carried).not.toMatch(/ARCHIVED_COMPONENT_|DISCARDED_COMPONENT_OUTPUT|IGNORED_CLIENT_COMPONENT/);
|
||
expect(f.prepareCloud.mock.calls[2][3]).toMatchObject({ projectPath: other.project.path });
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await service.conversation(agentId)).topic?.requests.at(-1)?.status).toBe('completed'));
|
||
const completed = (await service.conversation(agentId)).topic!;
|
||
expect(completed.id).toBe(first.id);
|
||
expect(completed.requests.map(turn => turn.teacherVersion)).toEqual([9, 9, 10]);
|
||
expect(completed.requests[2]).toMatchObject({ projectId: other.project.id, projectName: other.project.name,
|
||
sourceConversationId: otherSource.id, presentation: 'reply-v1', usage: { inputTokens: 20, outputTokens: 10 } });
|
||
expect(completed.requests[2].runtimeThreadId).not.toBe(first.requests[0].runtimeThreadId);
|
||
expect(completed.requests[0]).toEqual(oldTurn);
|
||
expect(completed.discussion).toEqual(legacy);
|
||
expect(completed.conversation?.discussions).toEqual(projectsArchive);
|
||
expect(JSON.parse(await readFile(manifestFile, 'utf8')).topic).toMatchObject({
|
||
discussion: legacy, conversation: { discussions: projectsArchive },
|
||
});
|
||
expect(JSON.parse(await readFile(turnFile, 'utf8'))).toEqual(oldTurn);
|
||
await expect(service.sendConversation(agentId, { ...nextInput, projectId: f.scope.projectId }))
|
||
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||
expect(f.run).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it('retains scoped continuous-chat diagnostics when cancellation resolves with usage', async () => {
|
||
const f = await fixture({ cloudTeacher: true, mockCloud: true });
|
||
const raw = '{"reply":"收到的未完成正文","tool":{"kind":"ideas"';
|
||
f.run.mockImplementationOnce(async (_messages, signal, onText) => {
|
||
onText(raw);
|
||
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
|
||
return { inputTokens: 33, outputTokens: 11 };
|
||
});
|
||
const input = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId,
|
||
requestId: crypto.randomUUID(), text: '聊聊这个数字' };
|
||
const topic = await f.service.sendConversation('cloud-teacher', input);
|
||
const scope = { agentId: 'cloud-teacher', projectId: '', sourceId: 'project' };
|
||
await f.service.cancel(scope, topic.id, input.requestId);
|
||
await vi.waitFor(async () => expect((await f.service.conversation('cloud-teacher')).topic?.requests[0].status).toBe('cancelled'));
|
||
const cancelled = (await f.service.conversation('cloud-teacher')).topic!.requests[0];
|
||
expect(cancelled).toMatchObject({ id: input.requestId, projectId: input.projectId,
|
||
sourceConversationId: input.sourceConversationId, teacherVersion: 9, presentation: 'reply-v1',
|
||
status: 'cancelled', response: '', unparsedResponse: raw, usage: { inputTokens: 33, outputTokens: 11 } });
|
||
await f.service.sendConversation('cloud-teacher', input);
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
const restarted = await f.restart();
|
||
expect((await restarted.conversation('cloud-teacher')).topic!.requests[0]).toEqual(cancelled);
|
||
});
|
||
|
||
it.each(['suggestions', 'reply'] as const)('parses only final %s JSON after the real local runner reads a project file', async (format) => {
|
||
const f = await fixture({
|
||
liveModel: true, modelInputLimit: 8000,
|
||
modelSystemPrompt: '帮助学生理解设计,结合实际进展给出建议。'.repeat(45),
|
||
sourceContext: { ...context, messages: [
|
||
{ id: 'current-question', role: 'user', text: '创建计数器' },
|
||
{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) },
|
||
] },
|
||
});
|
||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||
api_key: 'synthetic', base_url: 'https://teacher.invalid/v1', models: ['qwen'],
|
||
model_capabilities_v2: { schema_version: 2, models: {
|
||
qwen: { input_modalities: ['text'], output_modalities: ['text'], reasoning: { supported: false } },
|
||
} },
|
||
});
|
||
const tool = { kind: 'ideas', title: '计数器', items: [{ id: 'count', text: '从 42 开始计数', state: 'kept' }] };
|
||
const final = format === 'suggestions'
|
||
? { intro: '计数器从 42 开始,我们可以聊聊它怎么变。', questions: ['为什么从 42 开始?', '什么时候改变数字?'] }
|
||
: { reply: '把现在的计数方式放进来了。', quickReplies: [], tool };
|
||
const preamble = format === 'suggestions' ? '我先读取计数器文件。'
|
||
: JSON.stringify({ reply: '这是读取前的想法。', quickReplies: [], tool: { ...tool, title: '尚未核对的草案' } });
|
||
const fetch = vi.spyOn(teacherTransport, 'proxyAwareFetch')
|
||
.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: {
|
||
content: preamble,
|
||
tool_calls: [{ index: 0, id: 'read-counter', function: { name: 'read_project_file', arguments: '{"path":"counter.ts"}' } }],
|
||
}, finish_reason: 'tool_calls' }] }) + '\n\n'))
|
||
.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: { content: JSON.stringify(final) }, finish_reason: 'stop' }] }) + '\n\n'));
|
||
try {
|
||
const topic = await f.service.create(scope);
|
||
await f.service.send(scope, topic.id, {
|
||
requestId: crypto.randomUUID(), text: '一起看看计数器。', sourceConversationId: f.scope.sourceId,
|
||
...(format === 'suggestions' ? { intent: 'suggestions' as const } : {}),
|
||
});
|
||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
|
||
expect(fetch).toHaveBeenCalledTimes(2);
|
||
const continuation = JSON.parse(String(fetch.mock.calls[1][1]?.body));
|
||
expect(continuation.messages.at(-1).content).toContain('let count = 42;');
|
||
const saved = await f.service.read(scope, topic.id);
|
||
if (format === 'suggestions') {
|
||
expect(saved.requests[0].response).toBe(final.intro);
|
||
expect(saved.requests[0].suggestedQuestions).toEqual(final.questions);
|
||
} else {
|
||
expect(saved.requests[0].response).toBe(final.reply);
|
||
expect(saved.requests[0].discussionError).toBeUndefined();
|
||
expect(saved.discussion).toBeUndefined();
|
||
expect(saved.requests[0].presentation).toBe('reply-v1');
|
||
}
|
||
expect(saved.requests[0].response).not.toContain(preamble);
|
||
} finally {
|
||
vi.restoreAllMocks();
|
||
}
|
||
});
|
||
|
||
it.each(['local', 'yuxi'])('%s keeps legacy data passive while new replies, retries and cancellation work', async (runtime) => {
|
||
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
|
||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||
const initial = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
|
||
const legacy = { id: 'old', revision: 4, status: 'active', content: { kind: 'ideas', title: 'legacy-state-sentinel' } };
|
||
const directory = path.join(f.created.project.path, '.makelore/teacher-conversations', initial.accountId, 'project');
|
||
const archived = { ...initial, discussion: legacy };
|
||
await new TeacherTopicStore(directory).save(archived);
|
||
const service = await f.restart();
|
||
const input = { requestId: crypto.randomUUID(), text: '接着聊数字怎么变', sourceConversationId: f.scope.sourceId };
|
||
const quickReplies = ['【想试试】先增加一个数字', '【还不确定】数字什么时候改变'];
|
||
f.replyWith(JSON.stringify({ reply: '我们看看数字改变的时机。', quickReplies, tool: { kind: 'flow', title: 'discarded-output-sentinel' } }));
|
||
const pending = await service.send(scope, initial.id, input);
|
||
expect(pending.requests[0]).toMatchObject({ response: '', presentation: 'reply-v1' });
|
||
expect(pending.discussion).toEqual(legacy);
|
||
// Old cached send fields are ignored and cannot cause a duplicate request or change a component.
|
||
const staleInput = { ...input, presentation: 'discussion-v1', discussion: { toolId: 'old', revision: -1 } };
|
||
await service.send(scope, initial.id, staleInput);
|
||
expect(f.run).toHaveBeenCalledOnce();
|
||
const messages = f.run.mock.calls[0][0];
|
||
expect(messages).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
|
||
expect(JSON.stringify(messages)).not.toContain('legacy-state-sentinel');
|
||
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await service.read(scope, initial.id)).requests[0].status).toBe('completed'));
|
||
const completed = await service.read(scope, initial.id);
|
||
expect(completed.discussion).toEqual(legacy);
|
||
expect(completed.requests[0]).toMatchObject({ response: '我们看看数字改变的时机。', suggestedQuestions: quickReplies });
|
||
expect(completed.requests[0]).not.toHaveProperty('discussionSnapshot');
|
||
const next = { ...input, requestId: crypto.randomUUID(), text: quickReplies[0] };
|
||
await service.send(scope, initial.id, next);
|
||
await service.cancel(scope, initial.id, next.requestId);
|
||
await vi.waitFor(async () => expect((await service.read(scope, initial.id)).requests[1].status).toBe('cancelled'));
|
||
expect((await service.read(scope, initial.id)).discussion).toEqual(legacy);
|
||
const disk = await new TeacherTopicStore(directory).read(initial.id);
|
||
expect(disk.discussion).toEqual(legacy);
|
||
expect(disk.requests[0].suggestedQuestions).toEqual(quickReplies);
|
||
});
|
||
|
||
});
|
||
|
||
|
||
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: '看看项目', 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' };
|
||
const topic = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
|
||
expect(topic.role).toBeUndefined();
|
||
expect(topic.definition.teacher_id).toBe('coding-teacher');
|
||
expect((await f.service.list(scope)).items.find((item) => item.id === topic.id)?.teacherId)
|
||
.toBe(runtime === 'yuxi' ? 'cloud-teacher' : undefined);
|
||
await f.service.send(scope, topic.id, { requestId: crypto.randomUUID(), text: '你觉得怎么样?', sourceConversationId: f.scope.sourceId });
|
||
const messages = f.run.mock.calls[0][0];
|
||
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||
expect(messages[0].content).toContain('你可以通过只读工具');
|
||
if (runtime === 'yuxi') {
|
||
expect(f.prepareCloud.mock.calls[0][1].version).toBe(9);
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
} else expect(f.prepareModel).toHaveBeenCalledWith(expect.anything(), topic.definition,
|
||
expect.objectContaining({ projectPath: f.created.project.path }), { finalOnly: true });
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
|
||
});
|
||
|
||
it('uses the selected Yuxi topic and read scope for an automatic check-in', async () => {
|
||
const f = await fixture({ cloudTeacher: true, mockCloud: true });
|
||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||
const topic = await f.service.create(scope, undefined, undefined, 9);
|
||
const result = await f.service.checkIn(scope, {
|
||
requestId: crypto.randomUUID(), sourceConversationId: f.scope.sourceId,
|
||
});
|
||
expect(result.topic?.id).toBe(topic.id);
|
||
expect(result.topic?.requests[0]).toMatchObject({ intent: 'check-in', sourceConversationId: f.scope.sourceId });
|
||
const messages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>;
|
||
expect(messages.some(message => message.role === 'system' && message.content.includes('学生没有在本轮主动提问'))).toBe(true);
|
||
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||
expect(f.prepareCloud.mock.calls[0][3]).toMatchObject({ projectPath: f.created.project.path, source: context });
|
||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
|
||
});
|
||
});
|
||
|
||
it.each([false, true])('freezes explicit model without changing teacher definition (cloud=%s)', async cloudTeacher => {
|
||
const f = await fixture({ cloudTeacher, mockCloud: cloudTeacher });
|
||
const t = await f.service.create(f.scope, undefined, undefined, cloudTeacher ? 9 : undefined);
|
||
const input = { requestId: '22222222-2222-4222-8222-222222222222', text: '帮我理解', modelId: 'other-model' };
|
||
const accepted = await f.service.send(f.scope, t.id, input);
|
||
expect(accepted.requests[0].modelId).toBe('other-model');
|
||
expect(accepted.definition.model.model_id).toBe('qwen');
|
||
if (!cloudTeacher) expect(f.prepareModel.mock.calls[0][1].model).toEqual({ model_id: 'other-model', reasoning_choice: { mode: 'default' } });
|
||
await expect(f.service.send(f.scope, t.id, { ...input, modelId: 'qwen' })).rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||
await f.service.send(f.scope, t.id, input);
|
||
expect(f.run).toHaveBeenCalledTimes(1);
|
||
f.finish();
|
||
await vi.waitFor(async () => expect((await f.service.read(f.scope, t.id)).requests[0].status).toBe('completed'));
|
||
const restarted = await f.restart();
|
||
expect((await restarted.read(f.scope, t.id)).requests[0].modelId).toBe('other-model');
|
||
await expect(restarted.send(f.scope, t.id, { ...input, requestId: '33333333-3333-4333-8333-333333333333', modelId: 'unavailable' })).rejects.toMatchObject({ code: 'teacher_model_unavailable' });
|
||
expect(f.run).toHaveBeenCalledTimes(1);
|
||
});
|