321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
buildSimulatorSystemPrompt,
|
||
buildNarratorSystemPrompt,
|
||
buildSimulatorHistory,
|
||
isFirstSceneEntry,
|
||
} from '@/lib/pbl/v2/agents/simulator';
|
||
import type { PBLChatMessage, PBLAgentThread } from '@/lib/pbl/v2/types';
|
||
import { normalizeProjectRuntime, PBL_SIMULATOR_AGENT_ID } from '@/lib/pbl/v2/operations/progress';
|
||
import type { PBLProjectV2, PBLMilestone, PBLMicrotask } from '@/lib/pbl/v2/types';
|
||
|
||
function roleplayMilestone(): PBLMilestone {
|
||
return {
|
||
id: 'ms-rp',
|
||
title: '和林夏聊一聊',
|
||
status: 'active',
|
||
order: 1,
|
||
documents: [],
|
||
scenarioStage: 'roleplay',
|
||
briefing: '你坐在咖啡馆里,林夏刚到。',
|
||
microtasks: [
|
||
{
|
||
id: 'beat-1',
|
||
title: 'beat',
|
||
status: 'in_progress',
|
||
assignee: 'user',
|
||
hints: [],
|
||
order: 0,
|
||
description: '林夏坐在你对面,刚点了一杯热可可,眼睛有点红。',
|
||
completionCriteria: '学习者确认了林夏的情绪',
|
||
narration: '你们走进了一家安静的咖啡馆。',
|
||
} as unknown as PBLMicrotask,
|
||
],
|
||
} as unknown as PBLMilestone;
|
||
}
|
||
|
||
function scenarioProject(): PBLProjectV2 {
|
||
return {
|
||
language: 'zh-CN',
|
||
roles: [{ id: 'role-i', type: 'instructor', name: '教练' }],
|
||
threads: [{ agentId: 'role-i', messages: [] }],
|
||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||
scenario: {
|
||
setting: '校园咖啡馆的午后',
|
||
goal: '练习倾听与共情',
|
||
rules: '保持真诚,不评判',
|
||
learnerRole: '你是林夏的好朋友',
|
||
characters: [
|
||
{
|
||
id: 'c1',
|
||
name: '林夏',
|
||
persona: '内向,说话轻声细语',
|
||
situation: '这周失恋,情绪低落',
|
||
boundaries: '不会突然崩溃大哭',
|
||
},
|
||
],
|
||
},
|
||
milestones: [
|
||
{
|
||
id: 'ms-prep',
|
||
title: '准备',
|
||
status: 'completed',
|
||
order: 0,
|
||
documents: [],
|
||
scenarioStage: 'prep',
|
||
microtasks: [],
|
||
},
|
||
roleplayMilestone(),
|
||
{
|
||
id: 'ms-wrap',
|
||
title: '收尾',
|
||
status: 'locked',
|
||
order: 2,
|
||
documents: [],
|
||
scenarioStage: 'wrapup',
|
||
microtasks: [],
|
||
},
|
||
],
|
||
evaluations: [],
|
||
engagementEvents: [],
|
||
} as unknown as PBLProjectV2;
|
||
}
|
||
|
||
function plainProject(): PBLProjectV2 {
|
||
return {
|
||
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
|
||
threads: [{ agentId: 'role-i', messages: [] }],
|
||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||
milestones: [
|
||
{
|
||
id: 'ms-1',
|
||
title: 'M1',
|
||
status: 'active',
|
||
order: 0,
|
||
documents: [],
|
||
microtasks: [
|
||
{ id: 'mt-1', title: 'T1', status: 'in_progress', assignee: 'user', hints: [], order: 0 },
|
||
],
|
||
},
|
||
],
|
||
evaluations: [],
|
||
engagementEvents: [],
|
||
} as unknown as PBLProjectV2;
|
||
}
|
||
|
||
describe('PBL v2 — Simulator thread normalization (increment 3)', () => {
|
||
it('creates a Simulator thread for a scenario project', () => {
|
||
const p = scenarioProject();
|
||
expect(p.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toBe(false);
|
||
const changed = normalizeProjectRuntime(p);
|
||
expect(changed).toBe(true);
|
||
expect(p.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toBe(true);
|
||
});
|
||
|
||
it('is idempotent — does not duplicate the Simulator thread', () => {
|
||
const p = scenarioProject();
|
||
normalizeProjectRuntime(p);
|
||
normalizeProjectRuntime(p);
|
||
expect(p.threads.filter((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toHaveLength(1);
|
||
});
|
||
|
||
it('NEVER creates a Simulator thread for an ordinary (non-scenario) project', () => {
|
||
const p = plainProject();
|
||
normalizeProjectRuntime(p);
|
||
expect(p.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — Simulator system prompt (increment 3)', () => {
|
||
it('injects the concrete scene + cast (persona/situation/boundaries) and the project language', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toContain('校园咖啡馆的午后');
|
||
expect(prompt).toContain('林夏');
|
||
expect(prompt).toContain('这周失恋,情绪低落');
|
||
expect(prompt).toContain('保持真诚,不评判'); // rules
|
||
expect(prompt).toContain('你是林夏的好朋友'); // learnerRole
|
||
expect(prompt).toContain('和林夏聊一聊'); // current scene title
|
||
expect(prompt).toContain('zh-CN'); // language lock
|
||
});
|
||
|
||
it('carries zero teaching rules + forbids narrating/evaluating/guiding — it is a character, not a coach/narrator', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toMatch(/NOT the narrator, a coach, a teacher, an examiner, or a judge/i);
|
||
expect(prompt).toMatch(/Evaluate, praise, grade/i); // no judging the learner
|
||
expect(prompt).toMatch(/Ask the learner to explain, justify/i); // no quizzing reasoning
|
||
expect(prompt).toMatch(/participant/i); // pursues its own goals, not a facilitator
|
||
expect(prompt).toMatch(/never.*AI/i);
|
||
});
|
||
|
||
it('does NOT feed the beat completionCriteria (the pedagogical goal) into the character — that is what turned it into a coach', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).not.toMatch(/Private direction/i);
|
||
expect(prompt).not.toContain('学习者确认了林夏的情绪'); // completionCriteria must stay out of the character's mouth-prompt
|
||
});
|
||
|
||
it('grounds the character in the beat established facts (narration + description) so it cannot invent/contradict', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toMatch(/Established facts/i);
|
||
expect(prompt).toContain('林夏坐在你对面,刚点了一杯热可可'); // from description
|
||
expect(prompt).toMatch(/never invent or contradict/i);
|
||
});
|
||
|
||
it('B1′: injects the beat characterObjective as a PRIVATE aim (never narrated/coached) when authored', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
(ms.microtasks[0] as unknown as PBLMicrotask).characterObjective = '想知道你是否真的在乎';
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toContain('想知道你是否真的在乎');
|
||
expect(prompt).toMatch(/private aim/i);
|
||
expect(prompt).toMatch(/NEVER announce it, narrate it, evaluate/i);
|
||
});
|
||
|
||
it('B1′: omits the private-aim block entirely when the beat has no characterObjective', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).not.toMatch(/private aim this beat/i);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — Scene narrator system prompt (increment 3 — live narration)', () => {
|
||
it('is a pure third-person narrator: not a character, not a coach, with a NONE sentinel', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toMatch(/Scene Narrator/i);
|
||
expect(prompt).toMatch(/NOT a character and NOT a coach/i);
|
||
expect(prompt).toMatch(/never put a character's WORDS in their mouth/i);
|
||
expect(prompt).toContain('NONE'); // silent-when-nothing-happens sentinel
|
||
});
|
||
|
||
it('is grounded in the scene + beat facts and bounded (no skipping ahead)', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toContain('校园咖啡馆的午后'); // setting
|
||
expect(prompt).toContain('林夏坐在你对面,刚点了一杯热可可'); // established facts (description)
|
||
expect(prompt).toContain('林夏'); // cast name for reference
|
||
expect(prompt).toMatch(/NEVER skip ahead/i);
|
||
});
|
||
|
||
it('lets the narrator describe characters’ non-verbal actions/reactions but NEVER voice/summarize their speech (the fix for the narrator eating the character’s words)', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
// actions / reactions / body language ARE the narrator's job
|
||
expect(prompt).toMatch(/NON-VERBAL behaviour/i);
|
||
expect(prompt).toMatch(/我皱了皱眉/); // the example: a character cannot self-narrate this
|
||
// but it must never voice OR paraphrase what a character SAYS
|
||
expect(prompt).toMatch(/never summarize or paraphrase WHAT THEY SAY/i);
|
||
expect(prompt).toMatch(/their own turn/i);
|
||
// the dynamic cast line repeats the precise boundary with the actual names
|
||
expect(prompt).toMatch(/MAY describe their visible actions, reactions/i);
|
||
expect(prompt).toMatch(/NEVER speak for them or summarize\/paraphrase what they SAY/i);
|
||
});
|
||
|
||
it('returns just the base prompt for a non-scenario project (defensive)', () => {
|
||
const plain = plainProject();
|
||
const ms = plain.milestones[0];
|
||
const prompt = buildNarratorSystemPrompt(plain, ms, ms.microtasks[0]);
|
||
expect(prompt).toMatch(/Scene Narrator/i);
|
||
expect(prompt).not.toContain('## The scene');
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — buildSimulatorHistory audience split (role-bleed root-cause fix)', () => {
|
||
const thread = (): PBLAgentThread =>
|
||
({
|
||
agentId: PBL_SIMULATOR_AGENT_ID,
|
||
messages: [
|
||
{ id: 's1', roleType: 'system', content: '她皱了皱眉,身体往后靠了靠。', ts: 't' },
|
||
{ id: 'c1', roleType: 'simulator', content: '你这话什么意思?', ts: 't' },
|
||
{ id: 'u1', roleType: 'user', content: '我只是关心你。', ts: 't' },
|
||
],
|
||
}) as unknown as PBLAgentThread;
|
||
|
||
it('EXCLUDES system narration from the CHARACTER history (so it cannot imitate the 3rd-person prose — the root cause)', () => {
|
||
const out = buildSimulatorHistory(thread(), 'character');
|
||
expect(out).toEqual([
|
||
{ role: 'assistant', content: '你这话什么意思?' },
|
||
{ role: 'user', content: '我只是关心你。' },
|
||
]);
|
||
expect(JSON.stringify(out)).not.toContain('她皱了皱眉');
|
||
});
|
||
|
||
it('INCLUDES system narration (as Scene context) in the DIRECTOR history so scene-keeping stays continuous', () => {
|
||
const out = buildSimulatorHistory(thread(), 'director');
|
||
expect(out).toContainEqual({
|
||
role: 'system',
|
||
content: '(Scene: 她皱了皱眉,身体往后靠了靠。)',
|
||
});
|
||
expect(out).toContainEqual({ role: 'assistant', content: '你这话什么意思?' });
|
||
expect(out).toContainEqual({ role: 'user', content: '我只是关心你。' });
|
||
});
|
||
|
||
it('returns empty for an undefined thread (both audiences)', () => {
|
||
expect(buildSimulatorHistory(undefined, 'character')).toEqual([]);
|
||
expect(buildSimulatorHistory(undefined, 'director')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — character prompt forbids 3rd-person / narration imitation', () => {
|
||
it('explicitly bans writing in 3rd person about itself and imitating the narrator prose in history', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
expect(prompt).toMatch(/NEVER write in the third person about yourself/i);
|
||
expect(prompt).toMatch(/NOT your style and you must NOT copy it/i);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — director-narrator is pure narration (act model: no advance role)', () => {
|
||
it('narrator prompt no longer carries any beat-advance tool/role (the act model removed it)', () => {
|
||
const p = scenarioProject();
|
||
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
|
||
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
|
||
// The act model: the learner ends an act manually; the narrator only narrates.
|
||
expect(prompt).not.toMatch(/advance_scene_beat/);
|
||
expect(prompt).not.toMatch(/Behind the scenes/i);
|
||
// It still runs after the character and stays a pure third-person narrator.
|
||
expect(prompt).toMatch(/AFTER the character has spoken/i);
|
||
expect(prompt).toMatch(/NOT a character and NOT a coach/i);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — isFirstSceneEntry (inc4d openingLine gate)', () => {
|
||
const msg = (roleType: PBLChatMessage['roleType'], content = 'x'): PBLChatMessage => ({
|
||
id: `m-${Math.random().toString(16).slice(2)}`,
|
||
roleType,
|
||
content,
|
||
ts: 't',
|
||
});
|
||
|
||
it('is true for a truly empty thread', () => {
|
||
expect(isFirstSceneEntry([])).toBe(true);
|
||
expect(isFirstSceneEntry(undefined)).toBe(true);
|
||
});
|
||
|
||
it('stays true when only a system divider is present (prep→roleplay handover)', () => {
|
||
// The handover pushes a `system` divider into the simulator thread BEFORE
|
||
// the greeting runs — the authored openingLine must STILL be delivered.
|
||
expect(isFirstSceneEntry([msg('system', '[MILESTONE_DIVIDER]阶段推进:准备 → 第一幕')])).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('is false once the scene has actually started (a character or learner spoke)', () => {
|
||
expect(isFirstSceneEntry([msg('system'), msg('simulator')])).toBe(false);
|
||
expect(isFirstSceneEntry([msg('system'), msg('user')])).toBe(false);
|
||
});
|
||
});
|