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 & Pick) { 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(''); }); });