Files
openmaic/OpenMAIC/tests/pbl/v2/apply-instructor-event.test.ts
2026-08-16 14:58:47 +08:00

451 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
});
});