feat: integrate pinned teacher discussion workspace

This commit is contained in:
鲨鱼辣椒
2026-09-23 19:13:51 +08:00
parent 7951cca700
commit 404cfcde46
21 changed files with 1666 additions and 11 deletions

View File

@@ -9,6 +9,7 @@ const api = vi.hoisted(() => ({
create: vi.fn(),
read: vi.fn(),
send: vi.fn(),
updateDiscussion: vi.fn(),
cancel: vi.fn(),
save: vi.fn(),
events: vi.fn(),
@@ -448,3 +449,105 @@ describe('teacher side chat', () => {
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码'));
});
});
describe('pinned discussion workspace', () => {
const ideas = { id: 'tool', revision: 1, status: 'active' as const, content: {
kind: 'ideas' as const, title: '一起想我的宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }],
} };
async function openDiscussion(status: 'active' | 'offered' = 'active') {
const current = { ...structuredClone(first), discussion: { ...structuredClone(ideas), status } };
api.read.mockResolvedValue(current);
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await screen.findByText('一起想我的宠物游戏');
await waitFor(() => expect(streams.has('first')).toBe(true));
return current;
}
it('quotes a focused item into the existing composer without sending, then sends the locked version', async () => {
const current = await openDiscussion();
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 2, requests: [request({ text: input.text, response: '先聊聊小狗。' })] }));
fireEvent.click(screen.getByRole('button', { name: '养只小狗', exact: true }));
expect(api.send).not.toHaveBeenCalled();
expect(screen.getByText('接着聊:养只小狗')).toBeVisible();
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我希望是柯基' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/teacher', 'first', expect.objectContaining({ presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1, focusId: 'dog' }, text: '我希望是柯基' })));
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
});
it('finishes provisionally, keeps the draft, chats without changing the tool, and resumes explicitly', async () => {
const current = await openDiscussion();
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 2, discussion: { ...ideas, status: 'finished', revision: 2 } });
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '还在想的下一句' } });
fireEvent.click(screen.getByRole('button', { name: '先这些', exact: true }));
await screen.findByText('先留在这里');
expect(screen.getByLabelText('向老师提问')).toHaveValue('还在想的下一句');
expect(screen.getByTestId('teacher-discussion')).toHaveAttribute('data-collapsed', 'true');
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 3, discussion: { ...ideas, status: 'finished', revision: 2 }, requests: [request({ text: input.text, response: '我们慢慢聊。' })] }));
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('我们慢慢聊。');
expect(api.send.mock.calls[0][2].discussion).toBeUndefined();
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 4, discussion: { ...ideas, revision: 3 } });
fireEvent.click(screen.getByRole('button', { name: '接着改', exact: true }));
await screen.findByRole('button', { name: '养只小狗', exact: true });
expect(api.updateDiscussion.mock.calls[1][2]).toEqual({ toolId: 'tool', revision: 2, action: 'resume' });
});
it('offers a tool before activating, and preserves a draft during explicit structure conversion', async () => {
const current = await openDiscussion('offered');
api.updateDiscussion.mockResolvedValue({ ...current, revision: 2, discussion: { ...ideas, revision: 2 } });
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '用这个一起想', exact: true }));
await screen.findByRole('button', { name: '把想法理一理', exact: true });
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '不要丢掉的草稿' } });
api.send.mockResolvedValue({ ...current, revision: 3, discussion: { ...ideas, revision: 2 }, requests: [] });
fireEvent.click(screen.getByRole('button', { name: '把想法理一理', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalled());
expect(api.send.mock.calls[0][2].discussion).toEqual({ toolId: 'tool', revision: 2, transition: 'structure' });
expect(screen.getByLabelText('向老师提问')).toHaveValue('不要丢掉的草稿');
});
it('uses the same pinned component for SSE updates and leaves it untouched while thinking or stopping', async () => {
const current = await openDiscussion();
const stream = streams.get('first')!;
const updating = { ...current, revision: 2, requests: [request({ status: 'running', response: '', discussionContext: { toolId: 'tool', revision: 1 } })] };
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) })));
expect(screen.getByRole('status')).toHaveTextContent('正在梳理');
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeVisible();
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '下一轮草稿' } });
api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] });
fireEvent.click(screen.getByRole('button', { name: '停止回复' }));
await screen.findByText('这次先停在这里。想继续时可以再说说。');
expect(screen.getByLabelText('向老师提问')).toHaveValue('下一轮草稿');
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { ...ideas, revision: 2, content: { ...ideas.content, title: '一起想小狗和花园' } } }) })));
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
expect(screen.getByText('一起想小狗和花园')).toBeVisible();
});
it('sends a normal suggested question directly without changing the composer draft', async () => {
const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] };
api.read.mockResolvedValue(current);
api.send.mockResolvedValue({ ...current, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' });
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '自己的草稿' } });
fireEvent.click(screen.getByRole('button', { name: '怎样知道小狗喜欢我?' }));
await waitFor(() => expect(api.send).toHaveBeenCalled());
expect(api.send.mock.calls[0][2].text).toBe('怎样知道小狗喜欢我?');
expect(screen.getByLabelText('向老师提问')).toHaveValue('自己的草稿');
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
});
});
it('comparison focuses and directly sends the selected difference while preserving the existing draft', async () => {
const current = { ...first, discussion: { id: 'compare', revision: 3, status: 'active' as const, content: {
kind: 'comparison' as const, title: '它什么时候回应我?', columns: [{ id: 'a', label: '点击才回应' }, { id: 'b', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '等我点击' }, { columnId: 'b', text: '跑来欢迎我' }] }],
} } };
api.read.mockResolvedValue(current);
api.send.mockResolvedValue({ ...current, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await screen.findByText('回到家时');
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '自己还没说完的话' } });
fireEvent.click(screen.getAllByRole('button', { name: '聊聊这一点' })[1]);
await waitFor(() => expect(api.send).toHaveBeenCalled());
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '聊聊「回到家时」时,「主动回应」这一点:跑来欢迎我', discussion: { toolId: 'compare', revision: 3, focusId: 'home:b' } });
expect(screen.getByLabelText('向老师提问')).toHaveValue('自己还没说完的话');
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
});

View File

@@ -1156,3 +1156,44 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
describe('structured teacher service integration', () => {
it('buffers model JSON, offers once, locks updates, persists state, and never applies a cancelled update', async () => {
const f = await fixture();
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
const initial = await f.service.create(scope);
const tool = { kind: 'ideas', title: '宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] };
f.replyWith(JSON.stringify({ reply: '我们可以先把想法放在一起。', quickReplies: [], tool }));
const send = (text: string, discussion?: { toolId: string; revision: number }) => f.service.send(scope, initial.id, {
requestId: crypto.randomUUID(), text, presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId, discussion,
});
const pending = await send('想养只小狗');
expect(pending.requests[0].response).toBe('');
expect(pending.discussion).toBeUndefined();
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests[0].status).toBe('completed'));
const offered = await f.service.read(scope, initial.id);
expect(offered.discussion?.status).toBe('offered');
expect(offered.requests[0].response).toBe('我们可以先把想法放在一起。');
const active = await f.service.updateDiscussion(scope, initial.id, { toolId: offered.discussion!.id, revision: 1, action: 'enter' });
const context = { toolId: active.discussion!.id, revision: 2 };
f.replyWith(JSON.stringify({ reply: '把花园也放进来了。', tool: { ...tool, title: '小狗和花园' } }));
const running = await send('还想种花', context);
expect(running.discussion?.content.title).toBe('宠物游戏');
await expect(f.service.updateDiscussion(scope, initial.id, { ...context, action: 'finish' })).rejects.toMatchObject({ code: 'teacher_topic_busy' });
await f.service.cancel(scope, initial.id, running.requests.at(-1)!.id);
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('cancelled'));
expect((await f.service.read(scope, initial.id)).discussion?.content.title).toBe('宠物游戏');
await send('再把花园加进来', context);
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('completed'));
const completed = await f.service.read(scope, initial.id);
expect(completed.discussion?.content.title).toBe('小狗和花园');
expect(completed.discussion?.revision).toBe(3);
expect(completed.requests[0].discussionSnapshot?.title).toBe('宠物游戏');
await expect(send('旧版本', context)).rejects.toMatchObject({ code: 'teacher_discussion_changed' });
const restarted = await f.restart();
expect((await restarted.read(scope, initial.id)).discussion).toEqual(completed.discussion);
});
});

View File

@@ -0,0 +1,315 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
applyDiscussionReply,
discussionInstructions,
editDiscussion,
validateDiscussionContext,
} from '../../electron/coding-teacher/discussion';
import type {
TeacherDefinition,
TeacherDiscussionAction,
TeacherDiscussionContext,
TeacherRequest,
TeacherTopic,
} from '../../shared/coding-teacher';
import type { TeacherDiscussion, TeacherDiscussionContent } from '../../shared/teacher-discussion';
const definition: TeacherDefinition = {
schema_version: 1, teacher_id: 'coding-teacher', name: '老师', description: '', avatar_id: 'avatar-01',
welcome_message: '一起想一想', suggested_questions: [], system_prompt: '帮助孩子理解自己的想法。',
skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
};
const ideas: Extract<TeacherDiscussionContent, { kind: 'ideas' }> = {
kind: 'ideas', title: '小狗游戏', items: [
{ id: 'dog', text: '养一只自己的小狗', state: 'kept' },
{ id: 'breed', text: '柯基', parentId: 'dog', state: 'kept' },
{ id: 'adventure', text: '带它去冒险', state: 'suggested' },
], firstItemId: 'dog',
};
const structure: Extract<TeacherDiscussionContent, { kind: 'structure' }> = {
kind: 'structure', title: '这些想法的关系', nodes: [
{ id: 'dog', label: '自己的小狗' },
{ id: 'breed', label: '柯基', parentId: 'dog', relation: '品种' },
],
};
const flow: Extract<TeacherDiscussionContent, { kind: 'flow' }> = {
kind: 'flow', title: '回家时的回应', nodes: [
{ id: 'door', label: '打开门', kind: 'event' },
{ id: 'awake', label: '醒着吗', kind: 'condition' },
{ id: 'sleep', label: '继续睡', kind: 'outcome' },
], edges: [{ id: 'start', from: 'door', to: 'awake' }, { id: 'no', from: 'awake', to: 'sleep', label: '睡着' }],
};
const comparison: Extract<TeacherDiscussionContent, { kind: 'comparison' }> = {
kind: 'comparison', title: '两种提示',
columns: [{ id: 'direct', label: '直接说' }, { id: 'hint', label: '给线索' }],
rows: [{ id: 'new', label: '新玩家', cells: [{ columnId: 'direct', text: '马上知道做什么' }, { columnId: 'hint', text: '还需要判断' }] }],
};
function topic(content?: TeacherDiscussionContent, status: TeacherDiscussion['status'] = 'active'): TeacherTopic {
return {
schemaVersion: 1, revision: 1, id: 'topic', accountId: 'account', projectId: 'project',
sourceConversationId: 'source', version: 1, definition, createdAt: 'now', updatedAt: 'now', requests: [],
...(content ? { discussion: { id: 'tool-1', revision: 7, status, content: structuredClone(content) } } : {}),
};
}
function request(owner: TeacherTopic, context?: TeacherDiscussionContext): TeacherRequest {
const value: TeacherRequest = {
id: 'request-' + owner.requests.length, text: '我的补充', references: [], createdAt: 'now',
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [],
omittedMessages: 0, status: 'running', response: '', presentation: 'discussion-v1',
...(context ? { discussionContext: structuredClone(context) } : {}),
};
owner.requests.push(value);
return value;
}
function context(owner: TeacherTopic, extra: Partial<TeacherDiscussionContext> = {}): TeacherDiscussionContext {
return { toolId: owner.discussion!.id, revision: owner.discussion!.revision, ...extra };
}
function act(owner: TeacherTopic, action: TeacherDiscussionAction['action'], itemId?: string): void {
editDiscussion(owner, { ...context(owner), action, ...(itemId ? { itemId } : {}) });
}
function reply(content?: TeacherDiscussionContent | null, text = '我把你的想法接上了。'): string {
return JSON.stringify({ reply: text, quickReplies: ['接着说'], ...(content === undefined ? {} : { tool: content }) });
}
describe('teacher discussion lifecycle', () => {
it('offers a new component without activating it and keeps an independent history snapshot', () => {
const owner = topic();
const turn = request(owner);
applyDiscussionReply(owner, turn, reply(ideas));
expect(owner.discussion).toMatchObject({ revision: 1, status: 'offered', content: ideas });
expect(owner.discussion?.id).toMatch(/^[0-9a-f-]{36}$/);
expect(turn.response).toBe('我把你的想法接上了。');
expect(turn.suggestedQuestions).toEqual(['接着说']);
expect(turn.discussionSnapshot).toEqual(ideas);
expect(turn.discussionSnapshot).not.toBe(owner.discussion?.content);
act(owner, 'enter');
act(owner, 'aside-idea', 'breed');
expect(turn.discussionSnapshot).toEqual(ideas);
});
it('enters, pauses, resumes, finishes and reopens the same component without losing content', () => {
const owner = topic(ideas, 'offered');
const original = structuredClone(owner.discussion!);
for (const [action, status] of [
['enter', 'active'], ['pause', 'paused'], ['resume', 'active'], ['finish', 'finished'], ['resume', 'active'],
] as const) {
const priorRevision = owner.discussion!.revision;
act(owner, action);
expect(owner.discussion).toEqual({ ...original, status, revision: priorRevision + 1 });
}
expect(owner.requests).toEqual([]);
});
it('allows a first offer to be declined while retaining it for later use', () => {
const owner = topic(flow, 'offered');
act(owner, 'pause');
expect(owner.discussion).toMatchObject({ id: 'tool-1', status: 'paused', content: flow });
act(owner, 'resume');
expect(owner.discussion).toMatchObject({ id: 'tool-1', status: 'active', content: flow });
});
it('keeps and shelves ideas explicitly and removes a shelved first-item marker', () => {
const owner = topic(ideas);
act(owner, 'keep-idea', 'adventure');
act(owner, 'first-idea', 'adventure');
expect(owner.discussion!.content).toMatchObject({ firstItemId: 'adventure' });
act(owner, 'aside-idea', 'adventure');
expect(owner.discussion!.content).not.toHaveProperty('firstItemId');
expect(owner.discussion!.content).toMatchObject({ items: [ideas.items[0], ideas.items[1], { ...ideas.items[2], state: 'aside' }] });
const before = structuredClone(owner.discussion);
expect(() => act(owner, 'first-idea', 'adventure')).toThrowError();
expect(owner.discussion).toEqual(before);
});
it.each(['enter', 'pause', 'finish', 'resume', 'keep-idea', 'aside-idea', 'first-idea', 'back-ideas'] as const)(
'rejects stale %s actions without mutating the current component', action => {
const owner = topic(ideas);
const before = structuredClone(owner);
expect(() => editDiscussion(owner, { toolId: 'tool-1', revision: 6, action, itemId: 'dog' }))
.toThrowError(expect.objectContaining({ code: 'teacher_discussion_changed', status: 409 }));
expect(owner).toEqual(before);
}
);
it('rejects actions against another tool or a missing item without a partial edit', () => {
const owner = topic(ideas);
const before = structuredClone(owner);
expect(() => editDiscussion(owner, { toolId: 'other', revision: 7, action: 'pause' })).toThrowError();
expect(() => act(owner, 'keep-idea', 'missing')).toThrowError();
expect(() => act(owner, 'back-ideas')).toThrowError();
expect(owner).toEqual(before);
});
});
describe('discussion request context and focus', () => {
it('permits ordinary chat without context even when a component exists', () => {
expect(validateDiscussionContext(topic(flow))).toBeUndefined();
});
it.each([
[ideas, 'breed'], [structure, 'breed'], [flow, 'sleep'], [comparison, 'new:hint'],
] as const)('validates a stable focus id inside $0.kind', (content, focusId) => {
const owner = topic(content);
const expected = context(owner, { focusId });
expect(validateDiscussionContext(owner, { ...expected, clientOnly: 'drop me' } as TeacherDiscussionContext)).toEqual(expected);
expect(() => validateDiscussionContext(owner, context(owner, { focusId: 'missing' })))
.toThrowError(expect.objectContaining({ code: 'teacher_discussion_changed' }));
});
it.each(['offered', 'paused', 'finished'] as const)('rejects tool-maintenance context while %s', status => {
const owner = topic(flow, status);
expect(() => validateDiscussionContext(owner, context(owner))).toThrowError();
});
it('rejects stale revisions, foreign identities, invalid types and non-cell comparison focus', () => {
const owner = topic(comparison);
for (const input of [
context(owner, { revision: 6 }), context(owner, { toolId: 'other' }),
context(owner, { revision: 1.5 }), context(owner, { focusId: 'new' }),
{ ...context(owner), focusId: 3 }, { ...context(owner), transition: 'flow' },
]) {
expect(() => validateDiscussionContext(owner, input as TeacherDiscussionContext)).toThrowError();
}
const withFlow = topic(flow);
expect(() => validateDiscussionContext(withFlow, context(withFlow, { focusId: 'no' }))).toThrowError();
});
it('allows an explicit structure transition only from active ideas', () => {
const owner = topic(ideas);
const transition = context(owner, { transition: 'structure' });
expect(validateDiscussionContext(owner, transition)).toEqual(transition);
for (const content of [structure, flow, comparison]) {
const other = topic(content);
expect(() => validateDiscussionContext(other, context(other, { transition: 'structure' }))).toThrowError();
}
});
});
describe('applying completed teacher replies', () => {
it('updates the same active component and preserves all earlier turn snapshots', () => {
const owner = topic(flow);
const oldTurn = request(owner);
oldTurn.status = 'completed';
oldTurn.discussionSnapshot = structuredClone(flow);
const turn = request(owner, context(owner, { focusId: 'sleep' }));
const updated = structuredClone(flow);
updated.nodes[2].label = '动动耳朵,继续睡';
applyDiscussionReply(owner, turn, reply(updated));
expect(owner.discussion).toEqual({ id: 'tool-1', revision: 8, status: 'active', content: updated });
expect(turn.discussionSnapshot).toEqual(updated);
expect(oldTurn.discussionSnapshot).toEqual(flow);
expect(turn.discussionSnapshot).not.toBe(owner.discussion!.content);
updated.nodes[2].label = '改变调用方数据';
expect(turn.discussionSnapshot).toEqual(owner.discussion!.content);
});
it.each([ideas, structure, comparison])('rejects changing an active flow into $kind', content => {
const owner = topic(flow);
const before = structuredClone(owner.discussion);
const turn = request(owner, context(owner));
applyDiscussionReply(owner, turn, reply(content));
expect(owner.discussion).toEqual(before);
expect(turn.discussionError).toBeTruthy();
expect(turn.response).toBe('我把你的想法接上了。');
expect(turn.discussionSnapshot).toBeUndefined();
});
it('requires an explicit ideas-to-structure transition and preserves both directions for later discussion', () => {
const owner = topic(ideas);
const unconfirmed = request(owner, context(owner));
applyDiscussionReply(owner, unconfirmed, reply(structure));
expect(owner.discussion!.content).toEqual(ideas);
expect(unconfirmed.discussionError).toBeTruthy();
const transitionContext = validateDiscussionContext(owner, context(owner, { transition: 'structure' }));
const converted = request(owner, transitionContext);
applyDiscussionReply(owner, converted, reply(structure));
expect(owner.discussion).toMatchObject({ id: 'tool-1', revision: 8, status: 'active', content: structure, previousIdeas: ideas });
const expanded = structuredClone(structure);
expanded.nodes.push({ id: 'personality', label: '胆小但好奇', relation: '性格', parentId: 'dog' });
const supplement = request(owner, context(owner));
applyDiscussionReply(owner, supplement, reply(expanded));
act(owner, 'back-ideas');
expect(owner.discussion).toMatchObject({ id: 'tool-1', revision: 10, status: 'active', content: ideas, previousStructure: expanded });
const nextContext = context(owner, { transition: 'structure' });
const instructions = discussionInstructions(owner, nextContext);
expect(JSON.parse(instructions.split('\n').at(-1)!)).toMatchObject({ previousStructure: expanded });
act(owner, 'aside-idea', 'breed');
expect(owner.discussion!.previousIdeas).toEqual(ideas);
expect(owner.discussion!.previousStructure).toEqual(expanded);
expect(converted.discussionSnapshot).toEqual(structure);
expect(supplement.discussionSnapshot).toEqual(expanded);
});
it.each(['active', 'offered', 'paused', 'finished'] as const)('does not modify a %s component during ordinary chat', status => {
const owner = topic(flow, status);
const before = structuredClone(owner.discussion);
const turn = request(owner);
applyDiscussionReply(owner, turn, reply(ideas, '我们先聊这个想法。'));
expect(owner.discussion).toEqual(before);
expect(turn.response).toBe('我们先聊这个想法。');
expect(turn.discussionSnapshot).toBeUndefined();
});
it('ignores a late reply after revision/status changed while retaining its readable response', () => {
const owner = topic(flow);
const turn = request(owner, context(owner));
act(owner, 'pause');
const before = structuredClone(owner.discussion);
const update = structuredClone(flow);
update.nodes[2].label = '轻轻摇尾巴';
applyDiscussionReply(owner, turn, reply(update));
expect(owner.discussion).toEqual(before);
expect(turn.response).toBe('我把你的想法接上了。');
expect(turn.discussionSnapshot).toBeUndefined();
act(owner, 'resume');
const resumed = structuredClone(owner.discussion);
const stillStale = request(owner, turn.discussionContext);
applyDiscussionReply(owner, stillStale, reply(update));
expect(owner.discussion).toEqual(resumed);
});
it.each([null, undefined, flow])('keeps revision and snapshots unchanged when there is no component change: %s', content => {
const owner = topic(flow);
const before = structuredClone(owner.discussion);
const turn = request(owner, context(owner));
applyDiscussionReply(owner, turn, reply(content, '同一个动作,在不同情况下可以有不同反应。'));
expect(owner.discussion).toEqual(before);
expect(turn.response).toBe('同一个动作,在不同情况下可以有不同反应。');
expect(turn.discussionSnapshot).toBeUndefined();
expect(turn.discussionError).toBeUndefined();
});
it.each([
JSON.stringify({ reply: '你的补充收到啦。', tool: { ...flow, edges: [{ id: 'bad', from: 'door', to: 'missing' }] } }),
'{"reply":"你的补充收到啦。","tool":{"kind":"flow",',
])('keeps the old component and historical snapshot when a model update is invalid', raw => {
const owner = topic(flow);
const oldTurn = request(owner);
oldTurn.status = 'completed';
oldTurn.discussionSnapshot = structuredClone(flow);
const before = structuredClone(owner.discussion);
const turn = request(owner, context(owner));
applyDiscussionReply(owner, turn, raw);
expect(owner.discussion).toEqual(before);
expect(oldTurn.discussionSnapshot).toEqual(flow);
expect(turn.response).toBe('你的补充收到啦。');
expect(turn.discussionError).toBeTruthy();
expect(turn.discussionSnapshot).toBeUndefined();
});
it('keeps legacy text as a normal answer without creating a component', () => {
const owner = topic();
const turn = request(owner);
applyDiscussionReply(owner, turn, '先听听朋友玩的时候怎么想。');
expect(turn.response).toBe('先听听朋友玩的时候怎么想。');
expect(owner.discussion).toBeUndefined();
expect(turn.discussionSnapshot).toBeUndefined();
});
});

View File

@@ -0,0 +1,268 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import {
parseTeacherDiscussionContent,
parseTeacherDiscussionReply,
type TeacherDiscussionContent,
} from '../../shared/teacher-discussion';
const ideas: TeacherDiscussionContent = {
kind: 'ideas', title: '我想做的小狗游戏',
items: [
{ id: 'dog', text: '养一只小狗', state: 'kept' },
{ id: 'breed', text: '柯基', parentId: 'dog', state: 'kept' },
{ id: 'adventure', text: '一起冒险', state: 'suggested' },
{ id: 'garden', text: '种菜', state: 'aside' },
],
firstItemId: 'dog',
};
const structure: TeacherDiscussionContent = {
kind: 'structure', title: '这些想法有什么关系',
nodes: [
{ id: 'dog', label: '我的小狗' },
{ id: 'breed', label: '柯基', parentId: 'dog', relation: '品种' },
{ id: 'personality', label: '胆小但好奇', parentId: 'dog', relation: '性格' },
],
};
const flow: TeacherDiscussionContent = {
kind: 'flow', title: '回家时怎么欢迎我',
nodes: [
{ id: 'door', label: '打开家门', kind: 'event' },
{ id: 'awake', label: '小狗醒着吗?', kind: 'condition' },
{ id: 'welcome', label: '跑过来欢迎', kind: 'outcome' },
{ id: 'sleep', label: '动动耳朵,继续睡', kind: 'outcome' },
],
edges: [
{ id: 'start', from: 'door', to: 'awake' },
{ id: 'yes', from: 'awake', to: 'welcome', label: '醒着' },
{ id: 'no', from: 'awake', to: 'sleep', label: '睡着' },
],
};
const comparison: TeacherDiscussionContent = {
kind: 'comparison', title: '没打中时怎样回应',
columns: [{ id: 'penalty', label: '直接扣分' }, { id: 'hint', label: '先给提示' }],
rows: [{
id: 'new', label: '第一次玩的人',
cells: [
{ columnId: 'penalty', text: '可能不知道错在哪' },
{ columnId: 'hint', text: '更容易知道如何调整' },
],
}],
};
describe('teacher discussion content contract', () => {
it.each([ideas, structure, flow, comparison])('preserves Agent-provided $kind content in an independent safe projection', content => {
const parsed = parseTeacherDiscussionContent(content);
expect(parsed).toEqual(content);
expect(parsed).not.toBe(content);
expect(parseTeacherDiscussionContent(structuredClone(content))).toEqual(parsed);
});
it('preserves arbitrary structural relationships without inventing fixed character/place/item categories', () => {
expect(parseTeacherDiscussionContent({
kind: 'structure', title: '音乐盒', nodes: [
{ id: 'melody', label: '自己录的旋律' },
{ id: 'tempo', label: '慢慢加快', relation: '播放速度', parentId: 'melody' },
],
})).toEqual({
kind: 'structure', title: '音乐盒', nodes: [
{ id: 'melody', label: '自己录的旋律' },
{ id: 'tempo', label: '慢慢加快', relation: '播放速度', parentId: 'melody' },
],
});
});
it('accepts explicit game/retry loops while validating every edge target', () => {
const looping = {
...flow,
edges: [...flow.edges, { id: 'retry', from: 'sleep', to: 'door', label: '明天再来' }],
};
expect(parseTeacherDiscussionContent(looping)).toEqual(looping);
expect(() => parseTeacherDiscussionContent({
...looping, edges: [...looping.edges, { id: 'missing', from: 'welcome', to: 'outside' }],
})).toThrow(Error);
});
it('aligns comparison cells by their explicit column ids rather than their array position', () => {
const reversed = { ...comparison, rows: [{ ...comparison.rows[0], cells: [...comparison.rows[0].cells].reverse() }] };
expect(parseTeacherDiscussionContent(reversed)).toEqual(comparison);
});
it.each([
['duplicate item', { ...ideas, items: [ideas.items[0], ideas.items[0]] }],
['unknown parent', { ...structure, nodes: [{ id: 'child', label: '柯基', parentId: 'missing' }] }],
['self parent', { ...structure, nodes: [{ id: 'dog', label: '小狗', parentId: 'dog' }] }],
['parent cycle', { ...structure, nodes: [{ id: 'a', label: '甲', parentId: 'b' }, { id: 'b', label: '乙', parentId: 'a' }] }],
['idea cycle', { ...ideas, items: [{ id: 'a', text: '甲', state: 'kept', parentId: 'b' }, { id: 'b', text: '乙', state: 'kept', parentId: 'a' }] }],
['missing first item', { ...ideas, firstItemId: 'missing' }],
['aside first item', { ...ideas, firstItemId: 'garden' }],
['duplicate flow nodes', { ...flow, nodes: [...flow.nodes, flow.nodes[0]] }],
['duplicate edge', { ...flow, edges: [flow.edges[0], flow.edges[0]] }],
['unknown edge source', { ...flow, edges: [{ id: 'bad', from: 'missing', to: 'door' }] }],
['unknown node kind', { ...flow, nodes: [{ id: 'a', label: '运行脚本', kind: 'execute' }], edges: [] }],
['missing comparison cell', { ...comparison, rows: [{ ...comparison.rows[0], cells: comparison.rows[0].cells.slice(0, 1) }] }],
['duplicate comparison cell', { ...comparison, rows: [{ ...comparison.rows[0], cells: [comparison.rows[0].cells[0], comparison.rows[0].cells[0]] }] }],
['unknown comparison column', { ...comparison, rows: [{ ...comparison.rows[0], cells: [{ columnId: 'other', text: '其他' }, comparison.rows[0].cells[1]] }] }],
['duplicate comparison columns', { ...comparison, columns: [comparison.columns[0], comparison.columns[0]] }],
['duplicate comparison rows', { ...comparison, rows: [comparison.rows[0], comparison.rows[0]] }],
])('rejects broken graph/table identity: %s', (_name, content) => {
expect(() => parseTeacherDiscussionContent(content)).toThrow(Error);
});
it.each([
['empty title', { ...structure, title: ' ' }],
['long title', { ...structure, title: '字'.repeat(121) }],
['long label', { ...structure, nodes: [{ id: 'a', label: '字'.repeat(601) }] }],
['unsafe id', { ...structure, nodes: [{ id: 'node a', label: '甲' }] }],
['long id', { ...structure, nodes: [{ id: 'x'.repeat(65), label: '甲' }] }],
['empty nodes', { ...structure, nodes: [] }],
['too many nodes', { ...structure, nodes: Array.from({ length: 25 }, (_, i) => ({ id: 'n' + i, label: '节点' })) }],
['too many edges', { ...flow, edges: Array.from({ length: 41 }, (_, i) => ({ id: 'e' + i, from: 'door', to: 'awake' })) }],
['too many rows', { ...comparison, rows: Array.from({ length: 13 }, (_, i) => ({ ...comparison.rows[0], id: 'r' + i })) }],
['one comparison column', { ...comparison, columns: [comparison.columns[0]] }],
['unknown content kind', { kind: 'html', title: '网页', html: '<script>run()</script>' }],
['missing field', { kind: 'ideas', title: '想法' }],
])('rejects unbounded or unsupported content: %s', (_name, content) => {
expect(() => parseTeacherDiscussionContent(content)).toThrow(Error);
});
it('does not accept sparse arrays or invoke object accessors', () => {
expect(() => parseTeacherDiscussionContent({ ...structure, nodes: new Array(2) })).toThrow(Error);
const getter = vi.fn(() => '偷偷读取');
const content = { ...structure };
Object.defineProperty(content, 'title', { get: getter });
expect(() => parseTeacherDiscussionContent(content)).toThrow(Error);
expect(getter).not.toHaveBeenCalled();
const arrayGetter = vi.fn(() => structure.nodes[0]);
const nodes = [structure.nodes[0]];
Object.defineProperty(nodes, 0, { get: arrayGetter });
expect(() => parseTeacherDiscussionContent({ ...structure, nodes })).toThrow(Error);
expect(arrayGetter).not.toHaveBeenCalled();
});
it('drops extra display/execution fields and rejects dangerous object keys without polluting prototypes', () => {
const parsed = parseTeacherDiscussionContent({
...structure, html: '<svg onload="execute()"/>', action: 'run',
nodes: structure.nodes.map(node => ({ ...node, style: { color: 'red' }, onClick: 'execute()' })),
});
expect(parsed).toEqual(structure);
const polluted = JSON.parse('{"kind":"structure","title":"图","nodes":[{"id":"a","label":"节点","__proto__":{"polluted":true}}]}');
expect(() => parseTeacherDiscussionContent(polluted)).toThrow(Error);
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
expect(() => parseTeacherDiscussionContent(Object.assign(Object.create({ injected: true }), structure))).toThrow(Error);
expect(parseTeacherDiscussionContent(Object.assign(Object.create(null), structure))).toEqual(structure);
});
});
describe('teacher discussion model envelope', () => {
const envelope = { reply: '我把睡着的分支补好了。', quickReplies: ['还能怎样回应?'], tool: flow };
it.each([
(json: string) => json,
(json: string) => '```json\n' + json + '\n```',
(json: string) => '```JSON\r\n' + json + '\r\n```',
(json: string) => '```\n' + json + '\n```',
(json: string) => '```makelore-teacher-discussion\n' + json + '\n```',
(json: string) => '下面是整理。\n```json\n' + json + '\n```',
(json: string) => '下面是整理。\n' + json,
])('accepts structured JSON transport without exposing its envelope', wrap => {
expect(parseTeacherDiscussionReply(wrap(JSON.stringify(envelope)))).toEqual(envelope);
});
it('keeps legacy prose and ordinary programming examples without selecting a tool from keywords', () => {
const prose = '我们可以用流程图看看。\n```js\nif (awake) welcome();\n```';
expect(parseTeacherDiscussionReply(prose)).toEqual({ reply: prose, quickReplies: [] });
expect(parseTeacherDiscussionReply('柯基、冒险和菜园都是好点子。'))
.toEqual({ reply: '柯基、冒险和菜园都是好点子。', quickReplies: [] });
});
it('distinguishes an absent tool from an explicit null and supports a tool-only envelope', () => {
expect(parseTeacherDiscussionReply('{"reply":"这轮只解释一下。"}'))
.toEqual({ reply: '这轮只解释一下。', quickReplies: [] });
expect(parseTeacherDiscussionReply('{"reply":"先聊聊。","tool":null}'))
.toEqual({ reply: '先聊聊。', quickReplies: [], tool: null });
expect(parseTeacherDiscussionReply(JSON.stringify({ reply: '', tool: ideas })))
.toEqual({ reply: '', quickReplies: [], tool: ideas });
});
it('preserves the valid reply but omits an invalid tool so the service can keep the current component', () => {
const parsed = parseTeacherDiscussionReply(JSON.stringify({
reply: '你的补充我收到了。', quickReplies: ['接着聊'],
tool: { ...flow, edges: [{ id: 'broken', from: 'door', to: 'missing' }] },
}));
expect(parsed.reply).toBe('你的补充我收到了。');
expect(parsed.quickReplies).toEqual(['接着聊']);
expect(parsed.tool).toBeUndefined();
expect(parsed.toolError).toBeTruthy();
expect(JSON.stringify(parsed)).not.toContain('broken');
});
it.each([
'{"reply":"说明已经完整。","tool":{"kind":"flow",',
'```json\n{"reply":"说明已经完整。","tool":',
'整理结果:\n{"reply":"说明已经完整。","tool":',
'{"quickReplies":[],"tool":{"nodes":[]},"reply":"说明已经完整。",',
])('recovers a complete top-level reply from truncated JSON: %s', raw => {
const parsed = parseTeacherDiscussionReply(raw);
expect(parsed.reply).toBe('说明已经完整。');
expect(parsed.tool).toBeUndefined();
expect(parsed.toolError).toBeTruthy();
});
it.each([
'{"reply":"还没有说完',
'{"tool":{"reply":"不能拿嵌套结构充当正文"},',
'```json\n{"tool":{"secret":"raw-data"}}',
'```json\nnot valid json\n```',
'[{"reply":"不是合法 envelope"}]',
'{"reply":13,"tool":{"kind":"html"}}',
'```json\n{"reply":"结束"}\n```\n```json\n{"reply":"第二个"}\n```',
])('never exposes malformed JSON as the student reply: %s', raw => {
const parsed = parseTeacherDiscussionReply(raw);
expect(parsed.reply).not.toContain('{');
expect(parsed.reply).not.toContain('```');
expect(parsed.reply).not.toContain('raw-data');
expect(parsed.reply).not.toContain('不能拿嵌套结构');
expect(parsed.reply).not.toContain('不是合法 envelope');
expect(parsed.reply).toBeTruthy();
expect(parsed.tool).toBeUndefined();
expect(parsed.toolError).toBeTruthy();
});
it('recovers JSON-escaped reply strings without copying following malformed data', () => {
const reply = '你说:“先别叫醒它。”\n这个想法可以留下。';
const parsed = parseTeacherDiscussionReply('{"reply":' + JSON.stringify(reply) + ',"tool":{');
expect(parsed.reply).toBe(reply);
expect(parsed.toolError).toBeTruthy();
});
it('bounds transport by UTF-8 bytes and keeps only a recovered readable reply', () => {
const raw = JSON.stringify({ reply: '简短回复保留。', tool: flow, ignored: '中'.repeat(22000) });
expect(raw.length).toBeLessThan(64000);
expect(new TextEncoder().encode(raw).length).toBeGreaterThan(64000);
const parsed = parseTeacherDiscussionReply(raw);
expect(parsed.reply).toBe('简短回复保留。');
expect(parsed.tool).toBeUndefined();
expect(parsed.toolError).toBeTruthy();
});
it('bounds quick replies independently while retaining a valid component', () => {
expect(parseTeacherDiscussionReply(JSON.stringify({ reply: '想一想。', tool: structure, quickReplies: [' 再解释 ', '再解释'] })))
.toEqual({ reply: '想一想。', tool: structure, quickReplies: ['再解释'] });
for (const invalid of [['一', '二', '三', '四'], ['x'.repeat(121)], [13], ['']]) {
expect(parseTeacherDiscussionReply(JSON.stringify({ reply: '想一想。', tool: structure, quickReplies: invalid })))
.toEqual({ reply: '想一想。', tool: structure, quickReplies: [] });
}
});
it('bounds plain and structured reply text and does not propagate hostile envelope keys', () => {
expect(parseTeacherDiscussionReply('字'.repeat(12001)).reply).toHaveLength(12000);
const longReply = parseTeacherDiscussionReply(JSON.stringify({ reply: '字'.repeat(12001) }));
expect(longReply.reply.length).toBeLessThan(100);
expect(longReply.toolError).toBeTruthy();
const hostile = parseTeacherDiscussionReply('{"reply":"说明保留。","__proto__":{"polluted":true}}');
expect(hostile.reply).toBe('说明保留。');
expect(hostile.toolError).toBeTruthy();
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
});
});