// @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 = { 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 = { kind: 'structure', title: '这些想法的关系', nodes: [ { id: 'dog', label: '自己的小狗' }, { id: 'breed', label: '柯基', parentId: 'dog', relation: '品种' }, ], }; const flow: Extract = { 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 = { 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 { 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(); }); });