Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

321 lines
14 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 {
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 characters 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);
});
});