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

1017 lines
40 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 {
buildFirstTaskWorkspaceOrientationBlock,
buildInstructorRuntimeBrief,
buildPriorSubmissionsBlock,
buildScaffoldStateLine,
buildScenarioAwarenessBlock,
cleanInstructorCommitText,
cleanSetupFollowupText,
ensureNonEmptyInstructorMessages,
shouldHoldSetupFollowupPreview,
shouldReportEmptyOutput,
stageSynthesisOwed,
stripLeakedToolJson,
stripOrphanTrailingQuestion,
stripPrematureNextTaskSetup,
} from '@/lib/pbl/v2/agents/instructor';
import {
microtaskEngagement,
milestoneSynthesisSatisfied,
recordEvent,
} from '@/lib/pbl/v2/operations/kernel/engagement';
import type { PBLMilestone, PBLProjectV2 } from '@/lib/pbl/v2/types';
const now = '2026-05-29T00:00:00.000Z';
function milestone(args: Partial<PBLMilestone> & Pick<PBLMilestone, 'id' | 'title' | 'order'>) {
return {
status: 'locked',
description: '',
microtasks: [],
documents: [],
...args,
} satisfies PBLMilestone;
}
function makeProject(): PBLProjectV2 {
return {
uiPhase: 'workspace',
title: 'Build a HashMap Playground',
description:
'Create a tiny interactive project that lets a learner add, lookup, update, and delete keys while seeing collisions at a beginner-friendly level.',
learningObjective: 'Learn HashMap operations by building and testing a concrete toy tool.',
proficiency: 'beginner',
language: 'zh-CN',
tags: ['hashmap'],
status: 'active',
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
milestones: [
milestone({
id: 'ms-2',
title: 'Wire the interactive demo',
order: 1,
status: 'locked',
description: 'Turn the core operations into a visible playground.',
briefing: 'Connect each operation to a small learner-visible action.',
completionCriteria: 'The learner can demonstrate add, lookup, update, and delete.',
microtasks: [
{
id: 'mt-3',
title: 'Add buttons for each operation',
description: 'Create controls that trigger each HashMap operation.',
status: 'todo',
assignee: 'user',
hints: ['Start with add and lookup before delete.'],
order: 0,
},
],
documents: [],
}),
milestone({
id: 'ms-1',
title: 'Model the core HashMap behavior',
order: 0,
status: 'active',
description: 'Represent the map as buckets and make the basic operations work.',
briefing: 'Help the learner connect keys, hashes, buckets, and values.',
completionCriteria:
'The learner has working behavior and can explain what happens on lookup.',
microtasks: [
{
id: 'mt-2',
title: 'Implement lookup',
description: 'Use a key to find the right bucket and return the stored value.',
status: 'in_progress',
assignee: 'user',
hints: ['Ask what should happen when the key is missing.'],
order: 1,
},
{
id: 'mt-1',
title: 'Sketch buckets',
description: 'Draw or describe how keys land in buckets before coding.',
status: 'completed',
assignee: 'user',
hints: [],
order: 0,
},
],
documents: [],
}),
],
submissions: [
{
id: 'sub-1',
microtaskId: 'mt-2',
milestoneId: 'ms-1',
kind: 'text',
content: 'I wrote lookup and handled missing keys with undefined.',
createdAt: '2026-05-29T00:10:00.000Z',
},
],
evaluations: [
{
id: 'eval-1',
kind: 'task',
microtaskId: 'mt-2',
milestoneId: 'ms-1',
feedback: 'Older feedback before the latest submission.',
strengths: ['Understands buckets'],
improvements: ['Check missing keys'],
score: 70,
createdAt: '2026-05-29T00:05:00.000Z',
},
],
threads: [{ agentId: 'role-i', messages: [] }],
engagementEvents: [],
pendingHandover: {
completedMilestoneId: 'ms-0',
completedMilestoneTitle: 'Setup',
nextMilestoneId: 'ms-1',
nextMilestoneTitle: 'Model the core HashMap behavior',
nextTaskId: 'mt-1',
nextTaskTitle: 'Sketch buckets',
consumed: false,
},
createdAt: now,
updatedAt: now,
};
}
describe('PBL v2 — Instructor operating brief', () => {
it('anchors the instructor in project facts, roadmap, active task, and controls', () => {
const project = makeProject();
const activeMilestone = project.milestones[1];
const activeTask = activeMilestone.microtasks[0];
const brief = buildInstructorRuntimeBrief(project, activeMilestone, activeTask);
expect(brief).toContain('Build a HashMap Playground');
expect(brief).toContain('Learn HashMap operations');
expect(brief).toContain(
'Current location: milestone 1 "Model the core HashMap behavior", microtask 2/2 "Implement lookup"',
);
expect(brief).toContain('(2) Implement lookup [in progress');
expect(brief).toContain('Task intent: Use a key to find the right bucket');
expect(brief).toContain('Milestone 2: Wire the interactive demo [locked]');
expect(brief).toContain('latest submission is newer than the latest task evaluation');
expect(brief).toContain('right-side submission panel');
expect(brief).toContain('Continue button');
expect(brief).toContain('Pending stage handover');
});
it('instructs the LLM to drive difficulty changes via the adjust_difficulty tool (sole mechanism, no regex)', () => {
// There is no longer any per-message regex detector for learner difficulty
// requests — the runtime brief is what makes the LLM call adjust_difficulty,
// so this guards that the contract is present and unambiguous.
const project = makeProject();
const activeMilestone = project.milestones[1];
const activeTask = activeMilestone.microtasks[0];
const brief = buildInstructorRuntimeBrief(project, activeMilestone, activeTask);
expect(brief).toContain('adjust_difficulty');
expect(brief).toMatch(/EVERY learner message/i);
expect(brief).toMatch(/ONLY way difficulty changes/i);
// Must instruct SEMANTIC judgement, not keyword / fixed-pattern matching.
expect(brief).toMatch(/by MEANING — not by keywords/i);
});
it('treats the right-side submission panel as the only readiness path', () => {
const project = makeProject();
const milestone = project.milestones[1]; // ms-1
const noSubmissionTask = milestone.microtasks.find((t) => t.id === 'mt-1')!; // 0 submissions
const brief = buildInstructorRuntimeBrief(project, milestone, noSubmissionTask);
expect(brief).toContain('task readiness comes only from work submitted and evaluated');
expect(brief).toContain('right-side submission panel is REQUIRED');
expect(brief).toContain('Do not say that chat alone completed the task');
expect(brief).not.toContain('right-side submission panel is OPTIONAL');
expect(brief).not.toContain('completed directly in the chat');
expect(brief).not.toContain('do not route them to the panel');
});
it('does not reorder the project while building the prompt context', () => {
const project = makeProject();
const milestoneOrderBefore = project.milestones.map((m) => m.id);
const taskOrderBefore = project.milestones[1].microtasks.map((t) => t.id);
buildInstructorRuntimeBrief(
project,
project.milestones[1],
project.milestones[1].microtasks[0],
);
expect(project.milestones.map((m) => m.id)).toEqual(milestoneOrderBefore);
expect(project.milestones[1].microtasks.map((t) => t.id)).toEqual(taskOrderBefore);
});
});
describe('PBL v2 — first-task workspace orientation', () => {
function firstTaskProject(): PBLProjectV2 {
const project = makeProject();
project.milestones[1].microtasks[0].status = 'todo';
project.milestones[1].microtasks[1].status = 'in_progress';
return project;
}
it('adds workspace usage guidance for the first milestone first task in open-task phases', () => {
const project = firstTaskProject();
const milestone = project.milestones[1]; // order 0
const microtask = milestone.microtasks.find((t) => t.id === 'mt-1')!; // order 0
const block = buildFirstTaskWorkspaceOrientationBlock({
project,
milestone,
microtask,
phase: 'greeting',
});
expect(block).toContain('First-task workspace orientation');
expect(block).toContain('left side is the task sidebar');
expect(block).toContain('center is the Instructor interaction area');
expect(block).toContain('right side');
expect(block).toContain('final deliverable should be submitted on the right');
expect(block).toContain('copying/pasting text');
expect(block).toContain('PDF or an image/screenshot');
expect(block).toContain('feedback card');
expect(block).toContain('click the button that appears to advance');
expect(block).toContain('ask and discuss anything with the Instructor');
});
it('does not add the orientation for later tasks or normal instructing turns', () => {
const project = makeProject();
const milestone = project.milestones[1];
const laterTask = milestone.microtasks.find((t) => t.id === 'mt-2')!;
expect(
buildFirstTaskWorkspaceOrientationBlock({
project,
milestone,
microtask: laterTask,
phase: 'setup',
}),
).toBe('');
const firstTask = milestone.microtasks.find((t) => t.id === 'mt-1')!;
expect(
buildFirstTaskWorkspaceOrientationBlock({
project,
milestone,
microtask: firstTask,
phase: 'instructing',
}),
).toBe('');
});
it('NEVER adds the ordinary workspace orientation to a scenario project (it has its own prep briefing)', () => {
const project = makeProject();
// make it a scenario project; first milestone first task, greeting phase —
// the exact condition that would fire the orientation for an ordinary project.
project.scenario = {
setting: 's',
characters: [{ id: 'c1', name: '小皮', persona: 'p', situation: 'x' }],
} as PBLProjectV2['scenario'];
project.milestones[0].scenarioStage = 'prep';
const milestone = project.milestones[0];
const microtask = milestone.microtasks[0];
expect(
buildFirstTaskWorkspaceOrientationBlock({ project, milestone, microtask, phase: 'greeting' }),
).toBe('');
});
});
describe('PBL v2 — Instructor advance handoff text cleanup', () => {
it('dedupes an accidental repeated old-task wrap-up sentence', () => {
const result = cleanInstructorCommitText(
'对,这三行已经满足本步要求:print() 在 if 里面,只有 has_card 为 True 时才会输出“可以进门”。对,这三行已经满足本步要求:print() 在 if 里面,只有 has_card 为 True 时才会输出“可以进门”。',
);
expect(result.changed).toBe(true);
expect(result.text).toBe(
'对,这三行已经满足本步要求:print() 在 if 里面,只有 has_card 为 True 时才会输出“可以进门”。',
);
});
it('removes leaked observation tool JSON from committed instructor text', () => {
const result = stripLeakedToolJson(
'{"kind":"concept_unlocked","note":"学习者提交了 print(left_money),正确用 print 输出了剩余金额变量。","signature":"print_left_money_output"}可以,这一步已经能把剩余金额显示出来了。',
);
expect(result.changed).toBe(true);
expect(result.text).toBe('可以,这一步已经能把剩余金额显示出来了。');
});
it('wires next-task cleanup into committed instructor text when context is provided', () => {
const result = cleanInstructorCommitText(
[
'很好,你已经验证了值传递为什么不会改变原变量。',
'',
'现在进入第三步:运行程序并观察值传递的局限。我们先运行一次,看看输出。',
].join('\n'),
{ nextMicrotaskTitle: '运行程序并观察值传递的局限' },
);
expect(result.changed).toBe(true);
expect(result.text).toBe('很好,你已经验证了值传递为什么不会改变原变量。');
});
it('does not strip transition language from generic committed text without next-task context', () => {
const text = '下一步我们把代码运行一次,看看终端输出是否符合预期。';
expect(cleanInstructorCommitText(text)).toEqual({ text, changed: false });
});
it('keeps normal teaching questions unless the commit context asks for statement-only text', () => {
const text = '你觉得 input() 返回的是什么类型呢?';
expect(cleanInstructorCommitText(text)).toEqual({ text, changed: false });
expect(cleanInstructorCommitText(text, { stripFinalReverseQuestion: true })).toEqual({
text,
changed: false,
});
});
it('removes orphan final reverse-questions for statement-only commits', () => {
const result = cleanInstructorCommitText(
'很好,你已经让程序正确输出了问候。input() 拿到的内容为什么要先存进变量再用呢?',
{ stripFinalReverseQuestion: true },
);
expect(result.changed).toBe(true);
expect(result.text).toBe('很好,你已经让程序正确输出了问候。');
});
it('keeps the old-task wrap-up and removes a same-message next-task opener', () => {
const result = stripPrematureNextTaskSetup(
[
'很好,你已经验证了值传递为什么不会改变原变量。',
'',
'现在进入第三步:运行程序并观察值传递的局限。我们先运行一次,看看输出。',
].join('\n'),
'运行程序并观察值传递的局限',
);
expect(result.stripped).toBe(true);
expect(result.text).toBe('很好,你已经验证了值传递为什么不会改变原变量。');
});
it('leaves normal old-task feedback unchanged when it does not open the next task', () => {
const text = '很好,这一步你已经能说清楚 swap(a, b) 为什么只改了副本。';
const result = stripPrematureNextTaskSetup(text, '运行程序并观察值传递的局限');
expect(result).toEqual({ text, stripped: false });
});
it('also removes a clear next-task transition even when the title is paraphrased', () => {
const result = stripPrematureNextTaskSetup(
'你的解释是对的:值传递只改了函数里的副本。\n\n下一步我们开始做指针版本,先把函数参数改成地址。',
'编写指针版本的swap函数',
);
expect(result.stripped).toBe(true);
expect(result.text).toBe('你的解释是对的:值传递只改了函数里的副本。');
});
it('removes cross-milestone Continue guidance from the old-task wrap-up', () => {
const result = stripPrematureNextTaskSetup(
'这一阶段你已经把添加和展示清单跑通了。\n\n下一阶段我们会给清单加上"删除"功能——点击右侧的 Continue 按钮继续吧 👉',
undefined,
'实现删除功能',
);
expect(result.stripped).toBe(true);
expect(result.text).toBe('这一阶段你已经把添加和展示清单跑通了。');
});
it('removes previous-task praise from a setup opener after the divider', () => {
const result = cleanSetupFollowupText(
'很好,if 这一行已经像门禁机的“判断入口”了;现在我们要把判断成立时真正发生的事放进去。\n\n这一步的计划很简单:在 if has_card: 的下一行缩进 4 个空格,然后写一行 print()。',
);
expect(result.changed).toBe(true);
expect(result.text).toBe(
'现在我们要把判断成立时真正发生的事放进去。\n\n这一步的计划很简单:在 if has_card: 的下一行缩进 4 个空格,然后写一行 print()。',
);
});
it('removes vague setup lead-ins so the next action is explicit', () => {
const result = cleanSetupFollowupText(
'很接近第一个可运行成果了;这一步的意义是确认代码不只是“看起来对”,而是真的能在屏幕上产生预期输出。\n\n按这个小计划来:保持 has_card = True 不变,运行当前 Python 文件,然后看终端里是否出现“可以进门”。',
);
expect(result.changed).toBe(true);
expect(result.text).toBe(
'这一步的意义是确认代码不只是“看起来对”,而是真的能在屏幕上产生预期输出。\n\n保持 has_card = True 不变,运行当前 Python 文件,然后看终端里是否出现“可以进门”。',
);
});
it('holds early setup-followup tokens that still look like previous-task praise', () => {
const raw = '很好,上一任务已经完成得很稳';
expect(shouldHoldSetupFollowupPreview(raw, cleanSetupFollowupText(raw))).toBe(true);
});
it('releases setup-followup streaming once the cleaned next-task opener is available', () => {
const raw = '很好,上一任务已经完成得很稳;现在我们要读取用户输入的温度。';
const cleaned = cleanSetupFollowupText(raw);
expect(cleaned.changed).toBe(true);
expect(shouldHoldSetupFollowupPreview(raw, cleaned)).toBe(false);
expect(cleaned.text).toBe('现在我们要读取用户输入的温度。');
});
});
describe('PBL v2 — orphan trailing reverse-question cleanup', () => {
it('removes a trailing reverse-question sentence after the review', () => {
const result = stripOrphanTrailingQuestion(
'很好,你已经让程序正确输出了问候。input() 拿到的内容为什么要先存进变量再用呢?',
);
expect(result.changed).toBe(true);
expect(result.text).toBe('很好,你已经让程序正确输出了问候。');
});
it('cuts an explicit closing lead-in even when comma-joined to the praise', () => {
const result = stripOrphanTrailingQuestion(
'很好,你已经正确输出了问候,最后确认一下 input() 为什么要存进变量呢?',
);
expect(result.changed).toBe(true);
expect(result.text).toBe('很好,你已经正确输出了问候');
});
it('handles an English trailing question', () => {
const result = stripOrphanTrailingQuestion(
'Nice, your script greets the user. In your own words, why did we store input() in a variable?',
);
expect(result.changed).toBe(true);
expect(result.text).toBe('Nice, your script greets the user.');
});
it('leaves a statement-only message unchanged', () => {
const text = '很好,你已经正确输出了问候,方向完全对。';
const result = stripOrphanTrailingQuestion(text);
expect(result).toEqual({ text, changed: false });
});
it('never blanks a message that is only a single question', () => {
const text = '你觉得 input() 返回的是什么类型呢?';
const result = stripOrphanTrailingQuestion(text);
expect(result.changed).toBe(false);
expect(result.text).toBe(text);
});
it('peels multiple trailing question sentences but keeps the review', () => {
const result = stripOrphanTrailingQuestion(
'对,循环跑通了。你觉得 range() 的上界为什么不包含自己?再想想 step 是怎么回事?',
);
expect(result.changed).toBe(true);
expect(result.text).toBe('对,循环跑通了。');
});
it('P3: does not mis-cut a version/decimal number into a fragment', () => {
const result = stripOrphanTrailingQuestion('装好了,你用了 v2.0 对吧?');
expect(result.changed).toBe(false);
expect(result.text).toBe('装好了,你用了 v2.0 对吧?');
});
it('P3: an English decimal is not treated as a sentence boundary', () => {
const result = stripOrphanTrailingQuestion('You set version 2.5 already?');
expect(result.changed).toBe(false);
expect(result.text).toBe('You set version 2.5 already?');
});
it('P3: cuts at the sentence boundary before a lead-in (no dangling connector)', () => {
const result = stripOrphanTrailingQuestion(
'很好,程序跑起来了。我们最后看一下,为什么要先读输入呢?',
);
expect(result.changed).toBe(true);
expect(result.text).toBe('很好,程序跑起来了。');
});
it('P3: trims a dangling connector when a lead-in is comma+connector fused', () => {
const result = stripOrphanTrailingQuestion('不错,那我们最后确认一下为什么?');
expect(result.changed).toBe(true);
expect(result.text).toBe('不错');
});
});
describe('PBL v2 — stage synthesis checkpoint gate', () => {
it('is not owed when the milestone has no synthesisCheck', () => {
const project = makeProject();
const ms = project.milestones[1]; // ms-1, active
const lastTask = ms.microtasks.find((t) => t.id === 'mt-2')!;
expect(stageSynthesisOwed(project, ms, lastTask)).toBe(false);
});
it('is owed on the last microtask of a core (synthesisCheck) stage', () => {
const project = makeProject();
const ms = project.milestones[1]; // ms-1: mt-1 completed, mt-2 in_progress
ms.synthesisCheck = { coreConcept: '为什么用哈希能 O(1) 查找' };
const lastTask = ms.microtasks.find((t) => t.id === 'mt-2')!;
expect(stageSynthesisOwed(project, ms, lastTask)).toBe(true);
});
it('is NOT owed when the microtask is not the last of the core stage', () => {
const project = makeProject();
const ms = project.milestones[1];
ms.synthesisCheck = { coreConcept: '哈希查找' };
// Add a still-open sibling so mt-2 is no longer the last.
ms.microtasks.push({
id: 'mt-extra',
title: 'Extra step',
status: 'todo',
assignee: 'user',
hints: [],
order: 2,
});
const midTask = ms.microtasks.find((t) => t.id === 'mt-2')!;
expect(stageSynthesisOwed(project, ms, midTask)).toBe(false);
});
it('clears the gate once a stage_synthesis_check is recorded', () => {
const project = makeProject();
const ms = project.milestones[1];
ms.synthesisCheck = { coreConcept: '哈希查找' };
const lastTask = ms.microtasks.find((t) => t.id === 'mt-2')!;
expect(milestoneSynthesisSatisfied(project, ms.id)).toBe(false);
expect(stageSynthesisOwed(project, ms, lastTask)).toBe(true);
recordEvent(project, 'stage_synthesis_check', {
microtaskId: lastTask.id,
milestoneId: ms.id,
payload: {
question: '回看整个阶段,哈希为什么能让查找变快?',
learner_answer: '因为用 key 直接算出桶位置,不用一个个找。',
quality: 'strong',
},
});
expect(milestoneSynthesisSatisfied(project, ms.id)).toBe(true);
expect(stageSynthesisOwed(project, ms, lastTask)).toBe(false);
});
it('absorbs the microtask closing gate: a stage_synthesis_check sets the closing fields', () => {
const project = makeProject();
const ms = project.milestones[1];
const lastTask = ms.microtasks.find((t) => t.id === 'mt-2')!;
recordEvent(project, 'stage_synthesis_check', {
microtaskId: lastTask.id,
milestoneId: ms.id,
payload: {
question: '整个阶段你怎么总结?',
learner_answer: 'key→hash→bucket→value。',
quality: 'ok',
},
});
const summary = microtaskEngagement(project, lastTask.id);
expect(summary.closingQuestion).toBe('整个阶段你怎么总结?');
expect(summary.closingAnswer).toBe('key→hash→bucket→value。');
expect(summary.closingQuality).toBe('ok');
});
it('P2b: gate also accepts a closing_check on the last microtask (wrong-tool robustness)', () => {
const project = makeProject();
const ms = project.milestones[1]; // last microtask by order is mt-2
expect(milestoneSynthesisSatisfied(project, ms.id)).toBe(false);
recordEvent(project, 'closing_check', {
microtaskId: 'mt-2',
milestoneId: ms.id,
payload: { question: '为什么查找快?', learner_answer: '直接算桶位置。', quality: 'ok' },
});
expect(milestoneSynthesisSatisfied(project, ms.id)).toBe(true);
});
it('P2b: a closing_check on a NON-last microtask does not satisfy the gate', () => {
const project = makeProject();
const ms = project.milestones[1];
recordEvent(project, 'closing_check', {
microtaskId: 'mt-1', // completed, order 0 — not the last
milestoneId: ms.id,
payload: { question: 'q', learner_answer: 'a', quality: 'ok' },
});
expect(milestoneSynthesisSatisfied(project, ms.id)).toBe(false);
});
});
describe('PBL v2 — instructor sees earlier submissions across the project (#519)', () => {
it('digests an earlier task submission with how it was assessed', () => {
const project = makeProject();
// Make the evaluation NEWER than the submission so it is genuinely this
// submission's assessment (the fixture default has an older eval — see the
// stale-eval test below).
project.evaluations[0].createdAt = '2026-05-29T00:15:00.000Z';
// Active = mt-1 (no submission). mt-2 carries the only submission + eval.
const block = buildPriorSubmissionsBlock(project, 'mt-1');
expect(block).toContain('Model the core HashMap behavior / Implement lookup');
expect(block).toContain('handled missing keys with undefined'); // the submitted content
expect(block).toContain('score 70'); // how it was assessed
expect(block).toContain('to improve: Check missing keys');
});
it('does not borrow a stale score when the latest submission postdates the eval', () => {
// Fixture: submission at 00:10, evaluation at 00:05 → the learner has a
// newer, not-yet-evaluated version. The Instructor must NOT be told it
// "scored 70" (that score belongs to a previous version).
const project = makeProject();
const block = buildPriorSubmissionsBlock(project, 'mt-1');
expect(block).toContain('latest version not yet evaluated');
expect(block).not.toContain('score 70');
});
it('marks an earlier submission with no evaluation as not yet scored', () => {
const project = makeProject();
project.evaluations = [];
const block = buildPriorSubmissionsBlock(project, 'mt-1');
expect(block).toContain('not yet scored');
expect(block).not.toContain('score 70');
});
it("excludes the active task's own submission (no duplication of the current task)", () => {
const project = makeProject();
// mt-2 is the only task with a submission; when it is ACTIVE the prior
// block has nothing left to show.
expect(buildPriorSubmissionsBlock(project, 'mt-2')).toBe('');
});
it('returns empty when there are no submissions at all', () => {
const project = makeProject();
project.submissions = [];
expect(buildPriorSubmissionsBlock(project, 'mt-1')).toBe('');
});
it('drops entries that do not fit the budget and marks truncation', () => {
const project = makeProject();
const block = buildPriorSubmissionsBlock(project, 'mt-1', { maxChars: 200 });
expect(block).toContain('truncated to keep context bounded');
expect(block).not.toContain('handled missing keys with undefined');
});
});
describe('PBL v2 — scaffolding state release verdict (P2 ①②)', () => {
const base = {
learnerTurnCount: 1,
errorCount: 0,
repeatErrorCount: 0,
struggles: [] as string[],
questionsRaised: 0,
conceptsUnlocked: [] as string[],
};
it('HOLDs on the first attempt for a beginner', () => {
const line = buildScaffoldStateLine({ ...base }, { tier: 'beginner', submissionCount: 0 });
expect(line).toContain('HOLD');
});
it('RELEASEs for a beginner after one stuck signal (repeat error)', () => {
const line = buildScaffoldStateLine(
{ ...base, errorCount: 1, repeatErrorCount: 1 },
{ tier: 'beginner', submissionCount: 0 },
);
expect(line).toContain('RELEASE');
});
it('requires two units before RELEASE at intermediate', () => {
const one = buildScaffoldStateLine(
{ ...base, struggles: ['s1'] },
{ tier: 'intermediate', submissionCount: 0 },
);
expect(one).toContain('HOLD');
const two = buildScaffoldStateLine(
{ ...base, struggles: ['s1', 's2'] },
{ tier: 'intermediate', submissionCount: 0 },
);
expect(two).toContain('RELEASE');
});
it('counts genuine attempts (errors+submissions), not raw message count', () => {
// 5 chatty turns, zero real attempts → still HOLD for a beginner.
const line = buildScaffoldStateLine(
{ ...base, learnerTurnCount: 5 },
{ tier: 'beginner', submissionCount: 0 },
);
expect(line).toContain('HOLD');
});
it('treats a second genuine attempt as a release unit', () => {
const line = buildScaffoldStateLine(
{ ...base, errorCount: 2 },
{ tier: 'beginner', submissionCount: 0 },
);
expect(line).toContain('RELEASE');
});
it('suppressVerdict drops the verdict + instruction but keeps stats', () => {
const line = buildScaffoldStateLine(
{ ...base, errorCount: 1, repeatErrorCount: 1 },
{ tier: 'beginner', submissionCount: 0, suppressVerdict: true },
);
expect(line).not.toContain('RELEASE');
expect(line).not.toContain('HOLD');
expect(line).toContain('genuine attempts');
expect(line).toContain('stuck signals');
});
it('suppressVerdict keeps stats even when release would fire', () => {
// Same input that would trigger RELEASE without suppress — with
// suppress the verdict is absent but the data line is intact.
const line = buildScaffoldStateLine(
{ ...base, errorCount: 1, repeatErrorCount: 1 },
{ tier: 'beginner', submissionCount: 0, suppressVerdict: true },
);
expect(line).toContain('## Scaffolding state');
expect(line).toContain('learner messages');
expect(line).not.toMatch(/RELEASE|HOLD/);
});
it('treats an empty/unset tier as the no-evidence default (intermediate), not beginner', () => {
// Guards the DEFAULT_TIER consistency gap: the tier-guidance block already
// resolves '' → intermediate, so the scaffold label + release threshold
// must match (otherwise the prompt shows intermediate guidance + beginner
// disclosure ladder simultaneously).
const unset = buildScaffoldStateLine({ ...base }, { tier: '', submissionCount: 0 });
expect(unset).toContain('tier intermediate');
expect(unset).not.toContain('tier beginner');
// Empty tier must use the intermediate release threshold (2 units): one
// stuck signal HOLDs (a beginner would RELEASE here).
const oneSignal = buildScaffoldStateLine(
{ ...base, struggles: ['s1'] },
{ tier: '', submissionCount: 0 },
);
expect(oneSignal).toContain('HOLD');
expect(oneSignal).toContain('L3 after 2 unit');
});
});
describe('PBL v2 — shouldReportEmptyOutput (suppress only on real user-perceivable output)', () => {
// Reviewer finding (#593): the old predicate suppressed the empty-output
// error whenever ANY tool ran (`toolCalled`). That was too broad — a
// tool *call* is not the same as a user-perceivable result. A turn that
// only called an internal tool with no text can leave the learner with total
// silence. The predicate now keys off genuine user-perceivable output:
// scenario auto-completion (mainTurnAdvanced), committed text, or the
// difficulty ack (producedAck).
it('reports the empty-output error on a genuinely empty turn: no text, no advance, no ack', () => {
expect(
shouldReportEmptyOutput({
mainTurnAdvanced: false,
assistantText: ' ',
producedAck: false,
}),
).toBe(true);
});
it('reports empty output when a tool ran but produced NO user-perceivable result', () => {
// This is the reviewer case the old code wrongly suppressed: a bare
// internal tool call with no text, no scenario auto-completion, no ack →
// the learner saw nothing, so the retry fallback MUST fire.
expect(
shouldReportEmptyOutput({
mainTurnAdvanced: false,
assistantText: '',
producedAck: false,
}),
).toBe(true);
});
it('does NOT report empty output when the turn produced scenario auto-completion', () => {
expect(
shouldReportEmptyOutput({
mainTurnAdvanced: true,
assistantText: '',
producedAck: false,
}),
).toBe(false);
});
it('does NOT report empty output when a difficulty ack was committed', () => {
expect(
shouldReportEmptyOutput({
mainTurnAdvanced: false,
assistantText: '',
producedAck: true,
}),
).toBe(false);
});
it('does NOT report empty output when committed text exists', () => {
expect(
shouldReportEmptyOutput({
mainTurnAdvanced: false,
assistantText: '好的,我们换个角度来讲。',
producedAck: false,
}),
).toBe(false);
});
});
describe('PBL v2 — Instructor model message guard', () => {
it('adds a user anchor when history is empty', () => {
expect(ensureNonEmptyInstructorMessages([], '请介绍项目')).toEqual([
{ role: 'user', content: '请介绍项目' },
]);
});
it('keeps valid conversation history and trims blank content', () => {
expect(
ensureNonEmptyInstructorMessages(
[
{ role: 'user', content: ' 你好 ' },
{ role: 'assistant', content: ' 好的 ' },
{ role: 'user', content: ' ' },
],
'fallback',
),
).toEqual([
{ role: 'user', content: '你好' },
{ role: 'assistant', content: '好的' },
]);
});
it('keeps memory but still adds a user anchor when only system memory exists', () => {
expect(
ensureNonEmptyInstructorMessages(
[{ role: 'system', content: 'Earlier conversation memory' }],
'继续当前任务',
),
).toEqual([
{ role: 'system', content: 'Earlier conversation memory' },
{ role: 'user', content: '继续当前任务' },
]);
});
});
describe('PBL v2 — Instructor scenario awareness block (Increment 2)', () => {
// A coherent scenario project: fixed prep → scene → wrapup skeleton.
function scenarioProject(): PBLProjectV2 {
const p = makeProject();
p.scenario = {
setting: '校园咖啡馆的午后',
goal: '练习倾听与共情',
characters: [
{
id: 'char-1',
name: '林夏',
persona: '内向的同学,说话轻声细语',
situation: '这周失恋,情绪低落',
},
],
};
p.schemaVersion = 1;
p.milestones = [
milestone({
id: 'ms-prep',
title: '准备',
order: 0,
status: 'active',
scenarioStage: 'prep',
}),
milestone({ id: 'ms-scene', title: '和林夏聊一聊', order: 1, scenarioStage: 'roleplay' }),
milestone({ id: 'ms-wrap', title: '收尾', order: 2, scenarioStage: 'wrapup' }),
];
return p;
}
const prepOf = (p: PBLProjectV2) => p.milestones.find((m) => m.scenarioStage === 'prep')!;
const sceneOf = (p: PBLProjectV2) => p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const wrapOf = (p: PBLProjectV2) => p.milestones.find((m) => m.scenarioStage === 'wrapup')!;
it('returns empty string for an ordinary (non-scenario) project', () => {
expect(
buildScenarioAwarenessBlock({
project: makeProject(),
milestone: makeProject().milestones[0],
phase: 'greeting',
}),
).toBe('');
});
it('mentions setting, character (with situation), goal and scene stage', () => {
const p = scenarioProject();
const block = buildScenarioAwarenessBlock({
project: p,
milestone: prepOf(p),
phase: 'greeting',
});
expect(block).toContain('校园咖啡馆的午后');
expect(block).toContain('林夏');
expect(block).toContain('这周失恋,情绪低落');
expect(block).toContain('和林夏聊一聊');
expect(block).toContain('练习倾听与共情');
});
it('prep GREETING: specs the full structured opening briefing (8 parts + markdown + sidebar CTA, no impersonation)', () => {
const p = scenarioProject();
const block = buildScenarioAwarenessBlock({
project: p,
milestone: prepOf(p),
phase: 'greeting',
});
expect(block).toContain('write the OPENING briefing');
expect(block).toContain('LEFT sidebar');
// a couple of the mandatory parts
expect(block).toContain('warm greeting');
expect(block).toContain('self-introduction');
expect(block).toMatch(/not.*impersonate/i);
// Formatting stability fix: the briefing must explicitly OVERRIDE the global
// brevity / "don't format like a form" rules and make rich formatting a hard
// requirement, so the opener doesn't randomly collapse into a flat paragraph.
expect(block).toMatch(/EXCEPTION to the global brevity rules/i);
expect(block).toMatch(/Formatting is MANDATORY/i);
expect(block).toMatch(/section heading/i);
expect(block).toMatch(/bullet points/i);
expect(block).toMatch(/paragraph-only opening .* is WRONG/i);
// Prep gives the learner nothing to DO — no task / warm-up / quiz, and no
// question that expects an answer (only inviting THEM to ask the coach).
expect(block).toMatch(/Do NOT set any task, warm-up, mini-exercise, or quiz/i);
expect(block).toMatch(/End on part 8/i);
});
it('prep GREETING with scenario.rules: REQUIRES a rules section that teaches the rules', () => {
const p = scenarioProject();
p.scenario!.rules = '6 人局;翻前/翻后下注;牌型大小;Pot Odds 的含义';
const block = buildScenarioAwarenessBlock({
project: p,
milestone: prepOf(p),
phase: 'greeting',
});
expect(block).toMatch(/Rules — REQUIRED/i);
expect(block).toMatch(/bullet points/i);
// does NOT carry the "no special rule-set" escape hatch
expect(block).not.toMatch(/no special rule-set/i);
});
it('prep GREETING without scenario.rules: forbids inventing rules (e.g. comfort-a-friend)', () => {
const p = scenarioProject();
expect(p.scenario!.rules).toBeUndefined();
const block = buildScenarioAwarenessBlock({
project: p,
milestone: prepOf(p),
phase: 'greeting',
});
expect(block).toMatch(/no special rule-set/i);
expect(block).not.toMatch(/Rules — REQUIRED/i);
});
it('prep INSTRUCTING (follow-up): answer only, cannot advance, no impersonation', () => {
const p = scenarioProject();
const block = buildScenarioAwarenessBlock({
project: p,
milestone: prepOf(p),
phase: 'instructing',
});
expect(block).toContain('answering a follow-up');
expect(block).toMatch(/cannot.*advance/i);
expect(block).toMatch(/not.*impersonate/i);
});
it('wrapup stage: grounds the debrief in the real transcript; auto-closing, no confirm, no questions', () => {
const p = scenarioProject();
const block = buildScenarioAwarenessBlock({
project: p,
milestone: wrapOf(p),
phase: 'instructing',
});
expect(block).toContain('WRAPUP stage');
expect(block).toMatch(/light/i);
// Grounded in what actually happened (the role-play transcript), not invented.
expect(block).toMatch(/What actually happened in the scene/i);
expect(block).toMatch(/never invent/i);
// The session auto-completes — the instructor must not ask the learner to
// confirm / reply / click, and must not ask ANY question.
expect(block).toMatch(/session ENDS automatically/i);
expect(block).toMatch(/Do NOT ask the learner ANY question/i);
expect(block).toMatch(/Do NOT request a reply, confirmation/i);
// Explicit override of the reused teaching rules (so "ask a question / hand
// off the next action" cannot leak into a terminal summary).
expect(block).toMatch(/EXCEPTION to the teaching rules/i);
expect(block).toMatch(/NO next task/i);
});
it('scene stage: tells the instructor the scene is handed off + do not impersonate', () => {
const p = scenarioProject();
const block = buildScenarioAwarenessBlock({
project: p,
milestone: sceneOf(p),
phase: 'instructing',
});
expect(block).toContain('handed off');
expect(block).toMatch(/not.*impersonate/i);
});
it('degrades to empty when scenario has a cast but no scene stage', () => {
const p = scenarioProject();
p.milestones = p.milestones.map((m) => ({ ...m, scenarioStage: undefined }));
expect(
buildScenarioAwarenessBlock({ project: p, milestone: p.milestones[0], phase: 'greeting' }),
).toBe('');
});
it('degrades to empty when scenario has no characters', () => {
const p = scenarioProject();
p.scenario!.characters = [];
expect(
buildScenarioAwarenessBlock({ project: p, milestone: p.milestones[0], phase: 'greeting' }),
).toBe('');
});
});