Add contextual teacher help entry and simplify consultation panel

This commit is contained in:
鲨鱼辣椒
2026-09-22 16:55:07 +08:00
parent 68e676cb62
commit 12d800ec51
11 changed files with 603 additions and 50 deletions

View File

@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
import type { TeacherDefinition, TeacherRequest, TeacherSend, TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({
config: vi.fn(),
preview: vi.fn(),
@@ -47,6 +47,19 @@ function topic(id: string): TeacherTopic {
}
const first = topic('first'),
second = topic('second');
function request(overrides: Partial<TeacherRequest> = {}): TeacherRequest {
return {
id: 'request-1', text: '老师,帮我看看', references: [], createdAt: 'now',
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now',
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '',
...overrides,
};
}
const suggestedQuestions = ['怎样知道别人看懂了规则?', '我该先试哪个想法?'];
function suggestionsRequest(overrides: Partial<TeacherRequest> = {}): TeacherRequest {
return request({ intent: 'suggestions', response: '我们可以从最近遇到的这两个地方聊起。', suggestedQuestions, ...overrides });
}
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
vi.resetAllMocks();
@@ -144,10 +157,11 @@ describe('teacher side chat', () => {
expect(api.read).toHaveBeenCalled();
});
it.each([
['teacher', '老师', '我还没想好下一步做什么', '提问'],
['friend', '朋友', '想听听你对作品的第一印象', '发送给朋友'],
] as const)('only sends a %s suggestion after the student submits it', async (role, label, suggestion, sendLabel) => {
it('keeps friend suggestions as drafts until the student submits them', async () => {
const role = 'friend';
const label = '朋友';
const suggestion = '想听听你对作品的第一印象';
const sendLabel = '发送给朋友';
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.send.mockImplementation(async (_base, _id, input) => ({ ...first, requests: [{
id: input.requestId, text: input.text, references: [], createdAt: 'now',
@@ -174,6 +188,182 @@ describe('teacher side chat', () => {
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue('');
});
it.each(['teacher', 'friend'] as const)('opens %s without an extra project row or a model request', async (role) => {
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: { ...definition, suggested_questions: ['固定的通用问题'] } });
render(<TeacherChatPanel projectId="p" projectName="星星收集游戏" sourceId="c" role={role} />);
await waitFor(() => expect(screen.getByRole('textbox')).toBeEnabled());
expect(screen.queryByText('星星收集游戏')).not.toBeInTheDocument();
expect(screen.queryByText('当前项目')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
if (role === 'teacher') {
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled();
expect(screen.queryByRole('button', { name: '固定的通用问题' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '我还没想好下一步做什么' })).not.toBeInTheDocument();
} else {
expect(screen.getByRole('button', { name: '固定的通用问题' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '老师,帮我看看', exact: true })).not.toBeInTheDocument();
}
});
it('requests contextual suggestions explicitly, hides streaming JSON and preserves the freeform draft', async () => {
const streamingJson = '{"intro":"还没输出完","questions":["尚未确认的问题';
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [suggestionsRequest({ id: input.requestId, status: 'running', response: streamingJson, suggestedQuestions: undefined })],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
const help = screen.getByRole('button', { name: '老师,帮我看看', exact: true });
await waitFor(() => expect(help).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我自己还想问的另一件事' } });
fireEvent.click(help);
await screen.findByText('我看看你最近做到了哪里…');
expect(screen.queryByText(streamingJson)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledWith('p/teacher', 'first', expect.objectContaining({
intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: 'c',
}));
expect(screen.getByLabelText('向老师提问')).toHaveValue('我自己还想问的另一件事');
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeDisabled();
await waitFor(() => expect(streams.has('first')).toBe(true));
const sent = api.send.mock.calls[0][2] as TeacherSend;
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
...first, revision: 3, requests: [suggestionsRequest({ id: sent.requestId })],
}) })));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument();
for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled();
expect(screen.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toBeEnabled();
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled();
expect(api.send).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('向老师提问')).toHaveValue('我自己还想问的另一件事');
});
it.each([
[suggestedQuestions[0], undefined],
['我也说不清,你带我看看', 'guided-help'],
] as const)('sends the selected help action "%s" without replacing the student draft', async (text, intent) => {
const previous = suggestionsRequest();
api.read.mockResolvedValue({ ...first, requests: [previous] });
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [previous, request({ id: input.requestId, text: input.text, intent: input.intent, response: '我们先从你刚才做的那一步看起。' })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" quote={{ kind: 'code', text: '学生正在引用的一段代码' }} />);
await waitFor(() => expect(screen.getByRole('button', { name: text, exact: true })).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '还在整理的自由提问' } });
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: text, exact: true }));
await screen.findByText('我们先从你刚才做的那一步看起。');
expect(api.send).toHaveBeenCalledTimes(1);
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent).toMatchObject({ text, sourceConversationId: 'c' });
expect(sent.references).toEqual([]);
expect(sent.intent).toBe(intent);
expect(screen.getByLabelText('向老师提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
expect(screen.getByLabelText('向老师提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
});
it('retries an uncertain suggestions request after reopening without losing the student draft', async () => {
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await waitFor(() => expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我还没写完的问题' } });
fireEvent.click(screen.getByRole('button', { name: '老师,帮我看看', exact: true }));
await screen.findByText('网络暂不可用');
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
expect(originalRequest.intent).toBe('suggestions');
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await waitFor(() => expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled());
expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没写完的问题');
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我又补了一点自己的想法' } });
fireEvent.click(screen.getByRole('button', { name: '老师,帮我看看', exact: true }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
expect(screen.getByLabelText('向老师提问')).toHaveValue('我又补了一点自己的想法');
});
it('shows a failed suggestion request without leaking raw JSON and starts a fresh request on another try', async () => {
const failed = suggestionsRequest({ status: 'failed', response: '{"intro":"未完成', suggestedQuestions: undefined, error: '这次没有看清楚,请再试一次。' });
api.read.mockResolvedValue({ ...first, requests: [failed] });
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [failed, suggestionsRequest({ id: input.requestId })],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await screen.findByText('这次没有看清楚,请再试一次。');
expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '再请老师看看', exact: true }));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' });
expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id);
});
it.each(['reopen', 'stream'] as const)('reconciles an accepted request learned through %s before retrying a failed reply', async (via) => {
let accepted: TeacherRequest;
api.send.mockRejectedValueOnce(new Error('响应中途断开')).mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await waitFor(() => expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled());
await waitFor(() => expect(streams.has('first')).toBe(true));
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我的自由提问继续留着' } });
fireEvent.click(screen.getByRole('button', { name: '老师,帮我看看', exact: true }));
await screen.findByText('响应中途断开');
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
accepted = suggestionsRequest({ id: originalRequest.requestId, status: 'failed', response: '', suggestedQuestions: undefined, error: '老师已接到,但这次回复失败了' });
const recovered = { ...first, revision: 2, requests: [accepted] };
if (via === 'reopen') {
view.unmount();
api.read.mockResolvedValue(recovered);
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
} else {
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) })));
}
await screen.findByText('老师已接到,但这次回复失败了');
await waitFor(() => expect(screen.getByRole('button', { name: '再请老师看看', exact: true })).toBeEnabled());
expect(screen.getByLabelText('向老师提问')).toHaveValue('我的自由提问继续留着');
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '再请老师看看', exact: true }));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(api.send).toHaveBeenCalledTimes(2);
expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId);
expect(screen.getByLabelText('向老师提问')).toHaveValue('我的自由提问继续留着');
});
it('still submits a student-written question with its quote and clears only that submitted draft', async () => {
const quote = { kind: 'code' as const, text: 'setScore(score + 1)' };
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [request({
id: input.requestId, text: input.text, references: input.references ?? [], response: '你觉得每点一次,分数应该怎么变化?',
})],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" quote={quote} />);
await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '为什么分数没有增加?' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('你觉得每点一次,分数应该怎么变化?');
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '为什么分数没有增加?', references: [quote], sourceConversationId: 'c' });
expect(api.send.mock.calls[0][2].intent).toBeUndefined();
expect(screen.getByLabelText('向老师提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
expect(screen.getByLabelText('向老师提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
});
it('restores a closed draft and keeps teacher, friend and project drafts separate', async () => {
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
const closed = vi.fn();
@@ -241,7 +431,7 @@ describe('teacher side chat', () => {
});
it('keeps operations preview available without a consultation role', async () => {
api.preview.mockResolvedValue({ payload: definition, draft_revision: 7 });
api.preview.mockResolvedValue({ payload: { ...definition, suggested_questions: ['运营配置的预览问题'] }, draft_revision: 7 });
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.create.mockResolvedValue({ ...first, draftRevision: 7 });
render(<TeacherChatPanel projectId="preview" sourceId="sample" draftRevision={7} sampleContext="学生的练习代码" />);
@@ -249,6 +439,10 @@ describe('teacher side chat', () => {
expect(screen.getByText('运营草稿试聊')).toBeInTheDocument();
expect(api.preview).toHaveBeenCalledWith(7);
expect(api.config).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '运营配置的预览问题' }));
expect(screen.getByLabelText('向老师提问')).toHaveValue('运营配置的预览问题');
expect(api.send).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '老师,帮我看看', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '老师新话题' }));
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码'));
});

View File

@@ -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 = {