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

1017 lines
40 KiB
TypeScript

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