451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import { applyInstructorEvent } from '@/components/scene-renderers/pbl/v2/apply-instructor-event';
|
||
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
|
||
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
|
||
|
||
function makeProject(): PBLProjectV2 {
|
||
return {
|
||
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
|
||
threads: [{ agentId: 'role-i', messages: [] }],
|
||
updatedAt: '2026-05-29T00:00:00.000Z',
|
||
milestones: [
|
||
{
|
||
id: 'ms-1',
|
||
title: 'Milestone 1',
|
||
status: 'active',
|
||
order: 0,
|
||
documents: [],
|
||
microtasks: [
|
||
{
|
||
id: 'mt-1',
|
||
title: 'Task 1',
|
||
status: 'in_progress',
|
||
assignee: 'user',
|
||
hints: [],
|
||
order: 0,
|
||
},
|
||
{
|
||
id: 'mt-2',
|
||
title: 'Task 2',
|
||
status: 'todo',
|
||
assignee: 'user',
|
||
hints: [],
|
||
order: 1,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
evaluations: [],
|
||
engagementEvents: [],
|
||
} as unknown as PBLProjectV2;
|
||
}
|
||
|
||
describe('PBL v2 — apply SSE events: scenario message routing', () => {
|
||
function makeScenarioProject(): PBLProjectV2 {
|
||
const p = makeProject();
|
||
(p as unknown as { scenario: unknown }).scenario = {
|
||
setting: 's',
|
||
characters: [{ id: 'c1', name: '林夏', persona: 'p', situation: 'x' }],
|
||
};
|
||
p.threads.push({ agentId: 'simulator', messages: [] });
|
||
return p;
|
||
}
|
||
|
||
const instructorThread = (p: PBLProjectV2) => p.threads.find((t) => t.agentId === 'role-i')!;
|
||
const simulatorThread = (p: PBLProjectV2) => p.threads.find((t) => t.agentId === 'simulator')!;
|
||
|
||
it("routes a 'simulator' message to the simulator thread (not the instructor thread)", () => {
|
||
const project = makeScenarioProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'message',
|
||
message: {
|
||
id: 'sim-1',
|
||
agentId: 'simulator',
|
||
roleType: 'simulator',
|
||
characterId: 'c1',
|
||
content: '你来啦。',
|
||
ts: '2026-05-29T00:00:02.000Z',
|
||
microtaskId: 'mt-1',
|
||
},
|
||
},
|
||
};
|
||
const next = applyInstructorEvent(event, project, () => {});
|
||
expect(simulatorThread(next).messages).toHaveLength(1);
|
||
expect(simulatorThread(next).messages[0].roleType).toBe('simulator');
|
||
expect(instructorThread(next).messages).toHaveLength(0);
|
||
});
|
||
|
||
it("routes a 'system' narration message to the simulator thread", () => {
|
||
const project = makeScenarioProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'message',
|
||
message: {
|
||
id: 'sys-1',
|
||
agentId: 'simulator',
|
||
roleType: 'system',
|
||
content: '你们走进了一家安静的咖啡馆。',
|
||
ts: '2026-05-29T00:00:02.000Z',
|
||
microtaskId: 'mt-1',
|
||
},
|
||
},
|
||
};
|
||
const next = applyInstructorEvent(event, project, () => {});
|
||
expect(simulatorThread(next).messages).toHaveLength(1);
|
||
expect(instructorThread(next).messages).toHaveLength(0);
|
||
});
|
||
|
||
it('still routes instructor messages to the instructor thread on a scenario project', () => {
|
||
const project = makeScenarioProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'message',
|
||
message: {
|
||
id: 'ins-1',
|
||
agentId: 'role-i',
|
||
roleType: 'instructor',
|
||
content: '欢迎进入项目。',
|
||
ts: '2026-05-29T00:00:02.000Z',
|
||
microtaskId: 'mt-1',
|
||
},
|
||
},
|
||
};
|
||
const next = applyInstructorEvent(event, project, () => {});
|
||
expect(instructorThread(next).messages).toHaveLength(1);
|
||
expect(simulatorThread(next).messages).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — apply instructor SSE events', () => {
|
||
it('clears the live draft on reset_draft (advancing turn discards leaked prose)', () => {
|
||
// The server emits reset_draft the moment a turn advances, so a premature
|
||
// next-task mention that leaked into the live draft is dropped immediately
|
||
// instead of lingering until the isolated wrap-up message arrives.
|
||
let draft = '接下来下一阶段我们要做……'; // leaked next-task preview
|
||
const project = makeProject();
|
||
const event: PBLSSEEvent = { type: 'reset_draft' };
|
||
|
||
const next = applyInstructorEvent(event, project, (fn) => {
|
||
draft = fn(draft);
|
||
});
|
||
|
||
expect(draft).toBe('');
|
||
// No committed message is added by a draft reset.
|
||
expect(next.threads[0].messages).toHaveLength(0);
|
||
});
|
||
|
||
it('clears the live draft when a streamed assistant message is committed', () => {
|
||
let draft = '旧任务收尾回答';
|
||
const project = makeProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'message',
|
||
message: {
|
||
id: 'msg-1',
|
||
agentId: 'role-i',
|
||
roleType: 'instructor',
|
||
content: draft,
|
||
ts: '2026-05-29T00:00:01.000Z',
|
||
microtaskId: 'mt-1',
|
||
},
|
||
},
|
||
};
|
||
|
||
const next = applyInstructorEvent(event, project, (fn) => {
|
||
draft = fn(draft);
|
||
});
|
||
|
||
expect(next.threads[0].messages).toHaveLength(1);
|
||
expect(draft).toBe('');
|
||
});
|
||
|
||
it('does not persist embedded divider protocol markers in normal assistant messages', () => {
|
||
let draft = '';
|
||
const project = makeProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'message',
|
||
message: {
|
||
id: 'msg-1',
|
||
agentId: 'role-i',
|
||
roleType: 'instructor',
|
||
content:
|
||
'可以,这一步已经完成。[TASK_DIVIDER]任务完成:准备手算测试样例 | 开始下一任务:确定双指针起点',
|
||
ts: '2026-05-29T00:00:01.000Z',
|
||
microtaskId: 'mt-1',
|
||
},
|
||
},
|
||
};
|
||
|
||
const next = applyInstructorEvent(event, project, (fn) => {
|
||
draft = fn(draft);
|
||
});
|
||
|
||
expect(next.threads[0].messages[0].content).toBe('可以,这一步已经完成。');
|
||
});
|
||
|
||
it('keeps standalone divider messages so the chat can render them as dividers', () => {
|
||
let draft = '';
|
||
const project = makeProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'message',
|
||
message: {
|
||
id: 'msg-divider',
|
||
agentId: 'role-i',
|
||
roleType: 'instructor',
|
||
content: '[TASK_DIVIDER]任务完成:A | 开始下一任务:B',
|
||
ts: '2026-05-29T00:00:01.000Z',
|
||
microtaskId: 'mt-2',
|
||
},
|
||
},
|
||
};
|
||
|
||
const next = applyInstructorEvent(event, project, (fn) => {
|
||
draft = fn(draft);
|
||
});
|
||
|
||
expect(next.threads[0].messages[0].content).toBe(
|
||
'[TASK_DIVIDER]任务完成:A | 开始下一任务:B',
|
||
);
|
||
});
|
||
|
||
it('still appends token events into the live draft', () => {
|
||
let draft = '你好';
|
||
const project = makeProject();
|
||
|
||
applyInstructorEvent({ type: 'token', delta: '!' }, project, (fn) => {
|
||
draft = fn(draft);
|
||
});
|
||
|
||
expect(draft).toBe('你好!');
|
||
});
|
||
|
||
it('clears the live draft when a streamed evaluation is committed', () => {
|
||
let draft = '任务点评正文';
|
||
const project = makeProject();
|
||
|
||
const next = applyInstructorEvent(
|
||
{
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'evaluation',
|
||
evaluation: {
|
||
id: 'eval-task-1',
|
||
kind: 'task',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
feedback: '任务点评正文',
|
||
strengths: ['结构清楚'],
|
||
improvements: [],
|
||
score: 88,
|
||
createdAt: '2026-05-29T00:00:01.000Z',
|
||
},
|
||
},
|
||
},
|
||
project,
|
||
(fn) => {
|
||
draft = fn(draft);
|
||
},
|
||
);
|
||
|
||
expect(next.evaluations).toHaveLength(1);
|
||
expect(draft).toBe('');
|
||
});
|
||
|
||
it('applies authoritative server engagement events instead of reconstructing local ones', () => {
|
||
let draft = '';
|
||
const project = makeProject();
|
||
const next = applyInstructorEvent(
|
||
{
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'engagement_event',
|
||
event: {
|
||
id: 'evt-server-observation',
|
||
kind: 'observation_concept_unlocked',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:01.000Z',
|
||
payload: { signature: 'loop_guard' },
|
||
},
|
||
eventKind: 'observation_concept_unlocked',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
payload: { signature: 'loop_guard' },
|
||
},
|
||
},
|
||
project,
|
||
(fn) => {
|
||
draft = fn(draft);
|
||
},
|
||
);
|
||
|
||
expect(next.engagementEvents).toEqual([
|
||
{
|
||
id: 'evt-server-observation',
|
||
kind: 'observation_concept_unlocked',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:01.000Z',
|
||
payload: { signature: 'loop_guard' },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('merges authoritative advance snapshots so evaluation keeps task process evidence', () => {
|
||
let draft = '';
|
||
const project = makeProject();
|
||
const event: PBLSSEEvent = {
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'advance',
|
||
microtaskId: 'mt-1',
|
||
nextMicrotaskId: 'mt-2',
|
||
milestoneCompleted: false,
|
||
projectCompleted: false,
|
||
completedMicrotask: {
|
||
id: 'mt-1',
|
||
title: 'Task 1',
|
||
status: 'completed',
|
||
assignee: 'user',
|
||
hints: [],
|
||
order: 0,
|
||
completionReason: 'learner solved it',
|
||
internalAssessment: {
|
||
problems: 'minor syntax wobble',
|
||
resolution: 'fixed after prompt',
|
||
performance: 'steady',
|
||
},
|
||
engagement: {
|
||
learnerTurnCount: 3,
|
||
errorCount: 1,
|
||
conceptsUnlocked: ['loop_guard'],
|
||
},
|
||
},
|
||
nextMicrotask: {
|
||
id: 'mt-2',
|
||
title: 'Task 2',
|
||
status: 'in_progress',
|
||
assignee: 'user',
|
||
hints: [],
|
||
order: 1,
|
||
},
|
||
engagementEvents: [
|
||
{
|
||
id: 'evt-turn',
|
||
kind: 'learner_turn',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:01.000Z',
|
||
payload: { chars: 24 },
|
||
},
|
||
{
|
||
id: 'evt-observation',
|
||
kind: 'observation_concept_unlocked',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:01.500Z',
|
||
payload: { signature: 'loop_guard' },
|
||
},
|
||
{
|
||
id: 'evt-completed',
|
||
kind: 'microtask_completed',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:02.000Z',
|
||
payload: { reason: 'learner solved it' },
|
||
},
|
||
{
|
||
id: 'evt-opened',
|
||
kind: 'microtask_opened',
|
||
microtaskId: 'mt-2',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:03.000Z',
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
const next = applyInstructorEvent(event, project, (fn) => {
|
||
draft = fn(draft);
|
||
});
|
||
|
||
const completed = next.milestones[0].microtasks[0];
|
||
const opened = next.milestones[0].microtasks[1];
|
||
expect(completed.status).toBe('completed');
|
||
expect(completed.completionReason).toBe('learner solved it');
|
||
expect(completed.internalAssessment?.performance).toBe('steady');
|
||
expect(completed.engagement?.learnerTurnCount).toBe(3);
|
||
expect(completed.engagement?.conceptsUnlocked).toContain('loop_guard');
|
||
expect(opened.status).toBe('in_progress');
|
||
expect(next.engagementEvents.map((event) => event.kind)).toEqual([
|
||
'learner_turn',
|
||
'observation_concept_unlocked',
|
||
'microtask_completed',
|
||
'microtask_opened',
|
||
]);
|
||
});
|
||
|
||
it('deduplicates engagement events included in advance snapshots', () => {
|
||
let draft = '';
|
||
const project = makeProject();
|
||
project.engagementEvents.push({
|
||
id: 'evt-observation',
|
||
kind: 'observation_concept_unlocked',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:02.000Z',
|
||
payload: { signature: 'already_here' },
|
||
});
|
||
|
||
const next = applyInstructorEvent(
|
||
{
|
||
type: 'project_patch',
|
||
patch: {
|
||
kind: 'advance',
|
||
microtaskId: 'mt-1',
|
||
milestoneCompleted: false,
|
||
projectCompleted: false,
|
||
engagementEvents: [
|
||
{
|
||
id: 'evt-observation',
|
||
kind: 'observation_concept_unlocked',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:02.000Z',
|
||
payload: { signature: 'already_here' },
|
||
},
|
||
{
|
||
id: 'evt-completed',
|
||
kind: 'microtask_completed',
|
||
microtaskId: 'mt-1',
|
||
milestoneId: 'ms-1',
|
||
ts: '2026-05-29T00:00:03.000Z',
|
||
payload: { reason: 'completed after observation' },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
project,
|
||
(fn) => {
|
||
draft = fn(draft);
|
||
},
|
||
);
|
||
|
||
expect(next.engagementEvents.map((event) => event.id)).toEqual([
|
||
'evt-observation',
|
||
'evt-completed',
|
||
]);
|
||
expect(next.milestones[0].microtasks[0].status).toBe('completed');
|
||
});
|
||
});
|