Add contextual teacher help entry and simplify consultation panel
This commit is contained in:
@@ -5,10 +5,11 @@ 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, sourceContext } from '../../electron/coding-teacher/context';
|
||||
import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../../electron/coding-teacher/context';
|
||||
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
|
||||
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
||||
import { consultationDefinition } from '../../electron/coding-teacher/consultation-role';
|
||||
import { parseTeacherSuggestions } from '../../electron/coding-teacher/suggestions';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
createMemoryCodingProjectStorage,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
createCodingProjectConfigV2,
|
||||
} from '../../electron/coding-projects/project-config';
|
||||
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
||||
import type { ConsultationRole, TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
|
||||
import type { ConsultationRole, TeacherDefinition, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
|
||||
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
|
||||
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
|
||||
|
||||
@@ -78,8 +79,9 @@ async function fixture() {
|
||||
version = 1,
|
||||
accountCurrent = true;
|
||||
let finish: () => void = () => undefined;
|
||||
let reply = '计数器保存一个数字。';
|
||||
const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => {
|
||||
onText('计数器保存一个数字。');
|
||||
onText(reply);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||
@@ -123,6 +125,7 @@ async function fixture() {
|
||||
readSource,
|
||||
prepareModel,
|
||||
finish: () => finish(),
|
||||
replyWith: (text: string) => { reply = text; },
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
},
|
||||
@@ -458,6 +461,223 @@ describe('project teacher and friend consultations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(JSON.stringify(f.run.mock.calls[0][0])).toContain('只返回 JSON 对象');
|
||||
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(['not JSON', JSON.stringify({ intro, questions: ['只有一个问题?'] })])('fails malformed suggestions without displaying raw output or losing usage: %s', async (invalid) => {
|
||||
const f = await fixture();
|
||||
const scope = newScope(f);
|
||||
const topic = await f.service.create(scope);
|
||||
f.replyWith(invalid);
|
||||
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: 'failed', response: '', usage: { inputTokens: 20, outputTokens: 10 } });
|
||||
expect(completed.requests[0].suggestedQuestions).toBeUndefined();
|
||||
expect(completed.requests[0].error).toContain('请再试一次');
|
||||
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].response).toBe('');
|
||||
expect(disk.requests[0].suggestedQuestions).toBeUndefined();
|
||||
});
|
||||
|
||||
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('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('adds a one-question guided opening for this round without changing the teacher 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).toBeUndefined();
|
||||
const messages = f.run.mock.calls[0][0];
|
||||
expect(messages.at(-1).content).toContain('只发起一个具体、容易回答的交流起点');
|
||||
expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', []).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 friendScope = { ...newScope(f), role: 'friend' as const };
|
||||
const previewScope = { projectId: 'preview', sourceId: 'preview' };
|
||||
const friend = await f.service.create(friendScope);
|
||||
const legacy = await f.service.create(f.scope);
|
||||
const preview = await f.service.create(previewScope, 2, '示例项目');
|
||||
for (const [scope, topic] of [[friendScope, friend], [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 round-specific instructions and honestly starts from ideas with no source', () => {
|
||||
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.at(-1)?.content).toContain('不要编造');
|
||||
expect(() => compileTeacherContext(definition, empty, [], '老师,帮我看看', [], estimateTeacherTokens(ordinary.messages), 'suggestions'))
|
||||
.toThrow('超过上下文预算');
|
||||
});
|
||||
|
||||
it('trims and deduplicates valid suggestions, accepting plain JSON and standard JSON code fences', () => {
|
||||
const raw = JSON.stringify({ intro: ' ' + intro + ' ', questions: [' ' + questions[0], questions[1], questions[0] + ' '] });
|
||||
expect(parseTeacherSuggestions(raw)).toEqual({ intro, questions });
|
||||
expect(parseTeacherSuggestions('```json\n' + raw + '\n```')).toEqual({ intro, questions });
|
||||
expect(parseTeacherSuggestions('```\n' + raw + '\n```')).toEqual({ intro, questions });
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ intro: '', questions },
|
||||
{ intro: ' '.repeat(3), questions },
|
||||
{ intro: '字'.repeat(401), questions },
|
||||
{ intro, questions: [questions[0]] },
|
||||
{ intro, questions: [...questions, '第三个?', '第四个?'] },
|
||||
{ intro, questions: [questions[0], questions[0]] },
|
||||
{ intro, questions: [questions[0], ''] },
|
||||
{ intro, questions: [questions[0], '字'.repeat(121)] },
|
||||
{ intro, questions: [questions[0], 1] },
|
||||
])('rejects suggestion responses that cannot become two or three short questions: %j', (value) => {
|
||||
expect(() => parseTeacherSuggestions(JSON.stringify(value))).toThrow('请再试一次');
|
||||
});
|
||||
});
|
||||
|
||||
describe('teacher context and wire contract', () => {
|
||||
it('takes only complete user/assistant text and preserves the read cursor', () => {
|
||||
const snapshot = {
|
||||
|
||||
Reference in New Issue
Block a user