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

970 lines
35 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.
/**
* PBL v2 — Microtask / milestone progress operations.
*
* Pure functions that mutate `PBLProjectV2`. Called from:
* - the Instructor agent's `advance_micro_task` tool path
* - the `/api/pbl/v2/task/update` route (when the learner clicks
* Continue on a milestone handover card, or skips a task)
*
* Cross-milestone advance does NOT auto-open the next milestone.
* That gate is intentional: when a microtask is the last of its
* milestone, we mark the milestone completed and stage a
* `pendingHandover` payload that the workspace UI renders as a
* "Continue to Stage N+1" card. The next milestone stays LOCKED
* until the learner clicks Continue, at which point the API call
* sets the next milestone to ACTIVE and its first microtask to
* IN_PROGRESS.
*/
import type {
PBLChatMessage,
PBLProjectV2,
PBLMilestone,
PBLMicrotask,
PBLInternalAssessment,
PBLHandover,
} from '../types';
import { microtaskEngagement, recordEvent } from './engagement';
import { clearPendingTaskCompletion } from './task-completion';
import {
appendRuntimeEvent,
appendStatusChangedRuntimeEvent,
milestoneIdForMicrotask,
mintRuntimeEventId,
normalizationRepairEventId,
patchStatusChangedRuntimeEventId,
} from './runtime-events';
export const MILESTONE_DIVIDER_PREFIX = '[MILESTONE_DIVIDER]';
export const TASK_DIVIDER_PREFIX = '[TASK_DIVIDER]';
/** SCENARIO ONLY. Synthetic agent id for the Simulator's own chat
* thread. It is NOT a `roles[]` record — the cast lives as data on
* `project.scenario.characters`. A dedicated thread keeps the
* role-play conversation isolated from the Instructor's teaching
* history (so neither prompt pollutes the other). Absent on all
* ordinary projects. */
export const PBL_SIMULATOR_AGENT_ID = 'simulator';
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
export function currentMilestone(project: PBLProjectV2): PBLMilestone | undefined {
return project.milestones.find((m) => m.status === 'active');
}
export function currentMicrotask(
project: PBLProjectV2,
): { milestone: PBLMilestone; microtask: PBLMicrotask } | undefined {
const ms = currentMilestone(project);
if (!ms) return undefined;
const mt = ms.microtasks.find((t) => t.status === 'todo' || t.status === 'in_progress');
if (!mt) return undefined;
return { milestone: ms, microtask: mt };
}
/**
* Normalize runtime-only state that the UI and Instructor API both
* require after a project is generated or reloaded. Planner tools
* build the project structure, but the runtime contract is stricter:
* there must be one Instructor thread, one active milestone, and one
* current microtask. Keep this small and deterministic so normal
* generation and local test generation share the same behavior.
*/
export function normalizeProjectRuntime(project: PBLProjectV2): boolean {
let changed = false;
// Run the scenario skeleton repair on EVERY load (not just at generation):
// it deterministically fixes any milestone whose `scenarioStage` the planner
// left missing/invalid — which otherwise makes a middle act render as the
// Instructor (prep) thread instead of the live scene. Idempotent + a no-op
// for ordinary (non-scenario) projects, so it is safe to call here.
if (normalizeScenario(project)) changed = true;
const instructor = project.roles.find((r) => r.type === 'instructor');
if (instructor && !project.threads.some((t) => t.agentId === instructor.id)) {
project.threads.push({ agentId: instructor.id, messages: [] });
changed = true;
}
// SCENARIO ONLY. Role-play projects get an extra Simulator thread so
// the in-character conversation is stored apart from the Instructor's
// prep/wrapup teaching thread. Gated on `project.scenario`; ordinary
// projects never grow this thread and their runtime is byte-identical.
if (project.scenario && !project.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)) {
project.threads.push({ agentId: PBL_SIMULATOR_AGENT_ID, messages: [] });
changed = true;
}
if (project.pendingHandover && !project.pendingHandover.consumed) {
if (changed) {
project.updatedAt = new Date().toISOString();
}
return changed;
}
if (project.status !== 'completed' && project.milestones.length > 0) {
let active = project.milestones.find((m) => m.status === 'active');
if (!active) {
active =
project.milestones.find((m) => m.status !== 'completed') ??
project.milestones[project.milestones.length - 1];
if (active && active.status !== 'active') {
const from = active.status;
active.status = 'active';
appendStatusChangedRuntimeEvent(project, {
id: normalizationRepairEventId(project, 'milestone', active.id, from, active.status),
entityType: 'milestone',
entityId: active.id,
from,
to: active.status,
milestoneId: active.id,
});
changed = true;
}
}
const current = active?.microtasks.find(
(t) => t.status === 'todo' || t.status === 'in_progress',
);
const taskToOpen =
current ?? active?.microtasks.find((t) => t.status !== 'completed' && t.status !== 'skipped');
if (taskToOpen && taskToOpen.status !== 'in_progress') {
const from = taskToOpen.status;
taskToOpen.status = 'in_progress';
appendStatusChangedRuntimeEvent(project, {
id: normalizationRepairEventId(
project,
'microtask',
taskToOpen.id,
from,
taskToOpen.status,
),
entityType: 'microtask',
entityId: taskToOpen.id,
from,
to: taskToOpen.status,
microtaskId: taskToOpen.id,
milestoneId: active?.id,
});
changed = true;
}
}
if (changed) {
project.updatedAt = new Date().toISOString();
}
return changed;
}
/**
* Whether the learner has actually entered the project at least once.
* Drives the Hero's "Start project" vs "Continue project" button.
*
* NOTE: an `in_progress` microtask does NOT count — `normalizeProjectRuntime`
* opens the first task on a brand-new project too. The reliable signals
* are learner-produced or Instructor-delivered: a thread message (the
* GREETING opener), a submission / evaluation / engagement event, a
* terminal microtask, a completed milestone, a pending handover, or a
* finished project.
*/
export function hasStartedProject(project: PBLProjectV2): boolean {
if (project.uiPhase === 'completed' || project.status === 'completed') return true;
if (project.submissions.length > 0) return true;
if (project.evaluations.length > 0) return true;
if (project.engagementEvents.length > 0) return true;
if (project.pendingHandover) return true;
if (project.threads.some((thread) => thread.messages.length > 0)) return true;
return project.milestones.some(
(milestone) =>
milestone.status === 'completed' ||
milestone.microtasks.some(
(microtask) => microtask.status === 'completed' || microtask.status === 'skipped',
),
);
}
/**
* Wipe all learner PBL progress and return a fresh `hero`-phase project,
* equivalent to one that was just generated and never played. The
* project STRUCTURE (roles, milestone / microtask definitions, title,
* language, …) is preserved; only the runtime learning state is reset.
*
* Proficiency state (`proficiency` / `proficiencyAssessment`) is NOT
* touched: it belongs to the learner model, not PBL progress, and is
* owned by a separate runtime layer (PBL content vs learner runtime are
* being decoupled). Clearing it here would couple reset to profile /
* proficiency re-initialization and regress an intermediate/advanced
* learner back to the beginner tier on restart.
*
* Pure: returns a new project, does not mutate the input.
*/
export function resetProjectProgress(project: PBLProjectV2): PBLProjectV2 {
const reset: PBLProjectV2 = {
...project,
runtimeEvents: project.runtimeEvents ? [...project.runtimeEvents] : undefined,
runtimeResetEpoch: (project.runtimeResetEpoch ?? 0) + 1,
uiPhase: 'hero',
status: 'active',
submissions: [],
evaluations: [],
engagementEvents: [],
pendingHandover: undefined,
pendingTaskCompletion: undefined,
threads: project.threads.map((thread) => ({ agentId: thread.agentId, messages: [] })),
milestones: project.milestones.map((milestone, index) => ({
...milestone,
status: index === 0 ? 'active' : 'locked',
internalAssessment: undefined,
microtasks: milestone.microtasks.map((microtask) => ({
...microtask,
status: 'todo',
internalAssessment: undefined,
completionReason: undefined,
engagement: undefined,
})),
})),
updatedAt: new Date().toISOString(),
};
appendRuntimeEvent(reset, {
id: mintRuntimeEventId(),
kind: 'project_reset',
actorType: 'user',
ts: reset.updatedAt,
});
appendStatusChangedRuntimeEvent(reset, {
actorType: 'user',
entityType: 'ui_phase',
entityId: 'project',
from: project.uiPhase,
to: reset.uiPhase,
});
appendStatusChangedRuntimeEvent(reset, {
actorType: 'user',
entityType: 'project',
entityId: 'project',
from: project.status,
to: reset.status,
});
project.milestones.forEach((milestone, milestoneIndex) => {
const resetMilestone = reset.milestones[milestoneIndex];
if (!resetMilestone) return;
appendStatusChangedRuntimeEvent(reset, {
actorType: 'user',
entityType: 'milestone',
entityId: resetMilestone.id,
from: milestone.status,
to: resetMilestone.status,
milestoneId: resetMilestone.id,
});
milestone.microtasks.forEach((microtask, microtaskIndex) => {
const resetMicrotask = resetMilestone.microtasks[microtaskIndex];
if (!resetMicrotask) return;
appendStatusChangedRuntimeEvent(reset, {
actorType: 'user',
entityType: 'microtask',
entityId: resetMicrotask.id,
from: microtask.status,
to: resetMicrotask.status,
microtaskId: resetMicrotask.id,
milestoneId: resetMilestone.id,
});
});
});
return reset;
}
export function findMicrotask(
project: PBLProjectV2,
microtaskId: string,
): { milestone: PBLMilestone; microtask: PBLMicrotask } | undefined {
for (const ms of project.milestones) {
const mt = ms.microtasks.find((t) => t.id === microtaskId);
if (mt) return { milestone: ms, microtask: mt };
}
return undefined;
}
// ---------------------------------------------------------------------------
// Scenario (role-play) normalization — SCENARIO ONLY safety net
// ---------------------------------------------------------------------------
function genScenarioCharId(): string {
return 'char_' + Math.random().toString(16).slice(2, 8) + Math.random().toString(16).slice(2, 8);
}
/**
* SCENARIO ONLY. Idempotent safety net that keeps the role-play
* scenario data structurally coherent, degrading to a plain project
* (never crashing) when persisted / legacy / hand-edited data is
* incoherent. No-op for ordinary projects (no `scenario`, no `scene`).
*
* A scenario is *runnable* only when BOTH a cast (`project.scenario`
* with ≥1 valid character) AND ≥1 roleplay stage (`milestone.scenarioStage === 'roleplay'`)
* are present — the two must come as a pair. When that pairing is
* broken we restore consistency so the single gate
* "`project.scenario` present = scenario project" never lies:
*
* - No cast (`scenario` absent) but `scene` flags exist
* → clear the orphan `scene` flags (a scene with no cast can't
* run); the milestones become ordinary Instructor milestones.
* - `scenario` present but every character is structurally broken
* (missing `name` / `persona`)
* → the cast is unusable → drop `scenario` + `schemaVersion` and
* clear `scene` flags → clean ordinary project.
* - `scenario` + valid cast but NO scene milestone to host it
* → drop `scenario` + `schemaVersion` (cast has nowhere to
* appear) → clean ordinary project.
* - Coherent scenario → assign any missing character ids and keep it.
*
* On the happy path (Planner generated a coherent scenario, enforced by
* the completion gate + validation) this only assigns ids / is a no-op.
* Degradation therefore essentially never fires for freshly generated
* projects — it exists purely to make corrupt / legacy packages safe.
*
* Returns true if it mutated the project.
*/
export function normalizeScenario(project: PBLProjectV2): boolean {
let changed = false;
const stagedMilestones = project.milestones.filter((m) => m.scenarioStage !== undefined);
// No cast → any `scenarioStage` marker is an orphan. Clear it so the
// project is a clean ordinary project. (Also covers ordinary projects
// with no stage markers at all: the loop simply does nothing.)
if (!project.scenario) {
for (const m of stagedMilestones) {
delete m.scenarioStage;
changed = true;
}
if (changed) project.updatedAt = new Date().toISOString();
return changed;
}
const scenario = project.scenario;
// Drop structurally-broken characters (need name + persona); assign
// missing ids to the survivors.
const validChars = (scenario.characters ?? []).filter(
(c) =>
!!c &&
typeof c.name === 'string' &&
c.name.trim().length > 0 &&
typeof c.persona === 'string' &&
c.persona.trim().length > 0,
);
if (validChars.length !== (scenario.characters?.length ?? 0)) {
scenario.characters = validChars;
changed = true;
}
for (const c of scenario.characters) {
if (!c.id) {
c.id = genScenarioCharId();
changed = true;
}
}
const degradeToPlainProject = () => {
delete project.scenario;
delete project.schemaVersion;
for (const m of stagedMilestones) delete m.scenarioStage;
changed = true;
};
// Cast unusable (no valid characters) → degrade.
if (scenario.characters.length === 0) {
degradeToPlainProject();
if (changed) project.updatedAt = new Date().toISOString();
return changed;
}
// SCENARIO SKELETON REPAIR (deterministic; runs on every load → fixes new
// AND already-generated projects). The skeleton is FIXED: prep → roleplay(s)
// → wrapup. The planner occasionally omits or mangles a MIDDLE milestone's
// `scenarioStage`, leaving it undefined. At runtime an undefined-stage
// milestone is treated as non-roleplay (Instructor thread), so entering it
// mid-scene wrongly shows the prep briefing instead of the live scene (the
// act-transition bug). Coerce any milestone with a missing/invalid stage by
// position: first → prep, last → wrapup, everything in between → roleplay.
const allMs = project.milestones;
allMs.forEach((m, i) => {
if (
m.scenarioStage === 'prep' ||
m.scenarioStage === 'roleplay' ||
m.scenarioStage === 'wrapup'
) {
return;
}
m.scenarioStage = i === 0 ? 'prep' : i === allMs.length - 1 ? 'wrapup' : 'roleplay';
changed = true;
});
// Need at least one immersive roleplay stage to host the cast; otherwise
// the cast has nowhere to appear → degrade (keeps the single gate
// "`project.scenario` present = scenario project" honest). Read fresh after
// the skeleton repair above.
const hasRoleplay = allMs.some((m) => m.scenarioStage === 'roleplay');
if (!hasRoleplay) {
degradeToPlainProject();
if (changed) project.updatedAt = new Date().toISOString();
return changed;
}
// Coherent scenario. (Creating the Simulator thread + routing is wired
// in the increment that introduces the Simulator; nothing to do here.)
if (changed) project.updatedAt = new Date().toISOString();
return changed;
}
// ---------------------------------------------------------------------------
// State transitions
// ---------------------------------------------------------------------------
/** Mark a microtask `in_progress` (idempotent). */
export function startMicrotask(project: PBLProjectV2, microtaskId: string): void {
const found = findMicrotask(project, microtaskId);
if (!found) return;
const { milestone, microtask } = found;
// Activate parent milestone if it was LOCKED — happens after a
// milestone handover click.
if (milestone.status === 'locked') {
const from = milestone.status;
milestone.status = 'active';
appendStatusChangedRuntimeEvent(project, {
entityType: 'milestone',
entityId: milestone.id,
from,
to: milestone.status,
milestoneId: milestone.id,
});
}
if (microtask.status === 'todo') {
const from = microtask.status;
microtask.status = 'in_progress';
appendStatusChangedRuntimeEvent(project, {
entityType: 'microtask',
entityId: microtask.id,
from,
to: microtask.status,
microtaskId,
milestoneId: milestone.id,
});
recordEvent(project, 'microtask_opened', {
microtaskId,
milestoneId: milestone.id,
});
}
project.updatedAt = new Date().toISOString();
}
/** Advance the current microtask to `completed`. If this was the
* last microtask of the milestone, complete the milestone too and
* stage a `pendingHandover` for the workspace UI. */
export function advanceMicrotask(
project: PBLProjectV2,
microtaskId: string,
reason: string,
assessment: PBLInternalAssessment,
):
| {
ok: true;
milestoneCompleted: boolean;
projectCompleted: boolean;
nextMicrotaskId?: string;
}
| {
ok: false;
error: string;
} {
const found = findMicrotask(project, microtaskId);
if (!found) return { ok: false, error: 'microtask_not_found' };
const { milestone, microtask } = found;
if (microtask.status === 'completed' || microtask.status === 'skipped') {
return { ok: false, error: 'already_terminal' };
}
clearPendingTaskCompletion(project, microtaskId);
const microtaskStatusFrom = microtask.status;
microtask.status = 'completed';
appendStatusChangedRuntimeEvent(project, {
id: patchStatusChangedRuntimeEventId(
project,
'microtask',
microtask.id,
microtaskStatusFrom,
microtask.status,
),
entityType: 'microtask',
entityId: microtask.id,
from: microtaskStatusFrom,
to: microtask.status,
microtaskId,
milestoneId: milestone.id,
});
microtask.completionReason = reason;
microtask.internalAssessment = assessment;
recordEvent(project, 'microtask_completed', {
microtaskId,
milestoneId: milestone.id,
payload: { reason },
});
// Freeze the engagement summary onto the microtask itself once it's
// done. The engagement events ledger is a ring buffer (500-entry cap
// in engagement.ts) and a long project can roll its early task
// events off the back. By caching the summary at completion time we
// guarantee the milestone evaluator still has telemetry to feed the
// LLM, even when the underlying ledger has overflowed. This is the
// PR 6 evaluator's lifeline — without it, long projects evaluate
// against partial data and the LLM falls back to generic praise.
// We call microtaskEngagement AFTER recording the completion event
// so the snapshot includes a populated `completedAt` (used to
// derive `durationSeconds`).
microtask.engagement = microtaskEngagement(project, microtaskId);
// Find next not-yet-terminal microtask in the same milestone.
const next = milestone.microtasks.find((t) => t.status === 'todo' || t.status === 'in_progress');
if (next) {
const nextStatusFrom = next.status;
next.status = 'in_progress';
appendStatusChangedRuntimeEvent(project, {
id: patchStatusChangedRuntimeEventId(
project,
'microtask',
next.id,
nextStatusFrom,
next.status,
),
entityType: 'microtask',
entityId: next.id,
from: nextStatusFrom,
to: next.status,
microtaskId: next.id,
milestoneId: milestone.id,
});
recordEvent(project, 'microtask_opened', {
microtaskId: next.id,
milestoneId: milestone.id,
});
project.updatedAt = new Date().toISOString();
return {
ok: true,
milestoneCompleted: false,
projectCompleted: false,
nextMicrotaskId: next.id,
};
}
// No next microtask in this milestone — complete the milestone.
const milestoneStatusFrom = milestone.status;
milestone.status = 'completed';
appendStatusChangedRuntimeEvent(project, {
id: patchStatusChangedRuntimeEventId(
project,
'milestone',
milestone.id,
milestoneStatusFrom,
milestone.status,
),
entityType: 'milestone',
entityId: milestone.id,
from: milestoneStatusFrom,
to: milestone.status,
milestoneId: milestone.id,
microtaskId,
});
milestone.internalAssessment = assessment;
// Look for the next milestone in order.
const nextMs = project.milestones
.filter((m) => m.status === 'locked')
.sort((a, b) => a.order - b.order)[0];
if (nextMs) {
const firstTodo = nextMs.microtasks.find((t) => t.status === 'todo');
const handover: PBLHandover = {
completedMilestoneId: milestone.id,
completedMilestoneTitle: milestone.title,
nextMilestoneId: nextMs.id,
nextMilestoneTitle: nextMs.title,
nextTaskId: firstTodo?.id,
nextTaskTitle: firstTodo?.title,
consumed: false,
};
project.pendingHandover = handover;
appendRuntimeEvent(project, {
id: mintRuntimeEventId(),
kind: 'handover_staged',
actorType: 'system',
completedMilestoneId: handover.completedMilestoneId,
nextMilestoneId: handover.nextMilestoneId,
nextMicrotaskId: handover.nextTaskId,
ts: new Date().toISOString(),
milestoneId: milestone.id,
microtaskId,
});
project.updatedAt = new Date().toISOString();
return { ok: true, milestoneCompleted: true, projectCompleted: false };
}
// No next milestone — project is complete. Keep the UI in the
// workspace so the chained milestone/final evaluators can render
// their cards in chat. The learner enters the completion report
// explicitly via the final-evaluation CTA.
const projectStatusFrom = project.status;
project.status = 'completed';
appendStatusChangedRuntimeEvent(project, {
id: patchStatusChangedRuntimeEventId(
project,
'project',
'project',
projectStatusFrom,
project.status,
),
entityType: 'project',
entityId: 'project',
from: projectStatusFrom,
to: project.status,
milestoneId: milestone.id,
microtaskId,
});
project.updatedAt = new Date().toISOString();
return { ok: true, milestoneCompleted: true, projectCompleted: true };
}
/** SCENARIO ONLY. Complete an ENTIRE roleplay act (milestone) in one
* deterministic step, when the learner clicks "finish this act".
*
* The act model treats a roleplay milestone as ONE continuous scene whose
* beats are background checkpoints, NOT sequentially-advanced units — so the
* learner is never auto-advanced mid-scene. Progression is fully
* deterministic and user-driven: this marks every not-yet-terminal beat of
* the active roleplay milestone `completed`, then seals the milestone and
* stages the handover (reusing the exact same path `advanceMicrotask` uses
* for a milestone's last beat — so the existing "next stage" button picks it
* up unchanged). No LLM, no per-beat judgement: which checkpoints were
* actually met is judged later by the final evaluator for SCORING only, never
* for progression. Caller must have verified the active milestone is a
* roleplay stage; returns the same shape as `advanceMicrotask`. */
export function completeRoleplayAct(
project: PBLProjectV2,
reason: string,
):
| { ok: true; milestoneCompleted: boolean; projectCompleted: boolean }
| { ok: false; error: string } {
const milestone = currentMilestone(project);
if (!milestone || milestone.scenarioStage !== 'roleplay') {
return { ok: false, error: 'not_in_roleplay_act' };
}
const open = milestone.microtasks.filter(
(t) => t.status === 'todo' || t.status === 'in_progress',
);
if (open.length === 0) return { ok: false, error: 'already_terminal' };
// Server-side engagement gate: the learner must have actually played THIS act
// (≥1 learner message tagged to one of its beats) before it can be finished.
// The simulator thread is shared across all roleplay acts, so we scope by the
// act's beat ids — otherwise a later act could be completed without playing
// it (earlier acts' messages would satisfy a thread-wide check). This mirrors
// the sidebar's per-act button gate; it is the authoritative server check.
const beatIds = new Set(milestone.microtasks.map((b) => b.id));
const simThread = project.threads.find((t) => t.agentId === PBL_SIMULATOR_AGENT_ID);
const engagedThisAct = (simThread?.messages ?? []).some(
(m) => m.roleType === 'user' && !!m.microtaskId && beatIds.has(m.microtaskId),
);
if (!engagedThisAct) return { ok: false, error: 'act_not_engaged' };
// Complete every remaining beat deterministically (checkpoints, not gates).
// Freeze each beat's engagement snapshot exactly like advanceMicrotask, so
// the scenario final evaluator still has per-beat telemetry to score against.
for (const beat of open) {
const from = beat.status;
beat.status = 'completed';
appendStatusChangedRuntimeEvent(project, {
entityType: 'microtask',
entityId: beat.id,
from,
to: beat.status,
microtaskId: beat.id,
milestoneId: milestone.id,
});
beat.completionReason = reason;
recordEvent(project, 'microtask_completed', {
microtaskId: beat.id,
milestoneId: milestone.id,
payload: { reason },
});
beat.engagement = microtaskEngagement(project, beat.id);
}
// Seal the milestone, then advance to the next stage in ONE step. The act
// model treats a roleplay milestone as a self-contained scene, so finishing
// it should go straight to the next stage — no separate "continue" click.
// We stage the handover and immediately consume it (activating the next
// milestone's first task), exactly as if the learner had clicked Continue.
const milestoneStatusFrom = milestone.status;
milestone.status = 'completed';
appendStatusChangedRuntimeEvent(project, {
entityType: 'milestone',
entityId: milestone.id,
from: milestoneStatusFrom,
to: milestone.status,
milestoneId: milestone.id,
});
const nextMs = project.milestones
.filter((m) => m.status === 'locked')
.sort((a, b) => a.order - b.order)[0];
if (nextMs) {
const firstTodo = nextMs.microtasks.find((t) => t.status === 'todo');
project.pendingHandover = {
completedMilestoneId: milestone.id,
completedMilestoneTitle: milestone.title,
nextMilestoneId: nextMs.id,
nextMilestoneTitle: nextMs.title,
nextTaskId: firstTodo?.id,
nextTaskTitle: firstTodo?.title,
consumed: false,
};
appendRuntimeEvent(project, {
id: mintRuntimeEventId(),
kind: 'handover_staged',
actorType: 'system',
completedMilestoneId: milestone.id,
nextMilestoneId: nextMs.id,
nextMicrotaskId: firstTodo?.id,
ts: new Date().toISOString(),
milestoneId: milestone.id,
});
// One-step: consume the handover now so no "next stage" button is needed.
continueAfterHandover(project);
project.updatedAt = new Date().toISOString();
return { ok: true, milestoneCompleted: true, projectCompleted: false };
}
// No next milestone (shouldn't happen — a coherent scenario ends in wrapup).
const projectStatusFrom = project.status;
project.status = 'completed';
appendStatusChangedRuntimeEvent(project, {
entityType: 'project',
entityId: 'project',
from: projectStatusFrom,
to: project.status,
milestoneId: milestone.id,
});
project.updatedAt = new Date().toISOString();
return { ok: true, milestoneCompleted: true, projectCompleted: true };
}
/** Open the next milestone after the learner clicks Continue. */
export function continueAfterHandover(project: PBLProjectV2): {
ok: boolean;
activatedMicrotaskId?: string;
} {
const h = project.pendingHandover;
if (!h || h.consumed) return { ok: false };
const nextMs = project.milestones.find((m) => m.id === h.nextMilestoneId);
if (!nextMs) return { ok: false };
const nextMilestoneStatusFrom = nextMs.status;
nextMs.status = 'active';
appendStatusChangedRuntimeEvent(project, {
entityType: 'milestone',
entityId: nextMs.id,
from: nextMilestoneStatusFrom,
to: nextMs.status,
milestoneId: nextMs.id,
});
const first =
(h.nextTaskId && nextMs.microtasks.find((t) => t.id === h.nextTaskId)) ||
nextMs.microtasks.find((t) => t.status === 'todo');
if (first) {
const firstStatusFrom = first.status;
first.status = 'in_progress';
appendStatusChangedRuntimeEvent(project, {
entityType: 'microtask',
entityId: first.id,
from: firstStatusFrom,
to: first.status,
microtaskId: first.id,
milestoneId: nextMs.id,
});
recordEvent(project, 'microtask_opened', {
microtaskId: first.id,
milestoneId: nextMs.id,
});
}
project.pendingHandover = { ...h, consumed: true };
appendRuntimeEvent(project, {
id: mintRuntimeEventId(),
kind: 'handover_consumed',
actorType: 'system',
completedMilestoneId: h.completedMilestoneId,
nextMilestoneId: h.nextMilestoneId,
activatedMicrotaskId: first?.id,
ts: new Date().toISOString(),
milestoneId: nextMs.id,
microtaskId: first?.id,
});
appendMilestoneDividerMessage(project, h, first?.id);
project.updatedAt = new Date().toISOString();
return { ok: true, activatedMicrotaskId: first?.id };
}
export function appendTaskDividerMessage(
project: PBLProjectV2,
args: {
completedMicrotaskId: string;
nextMicrotaskId?: string;
completedTitle?: string;
nextTitle?: string;
},
): void {
if (!args.nextMicrotaskId) return;
const instructor = project.roles.find((r) => r.type === 'instructor');
if (!instructor) return;
const thread = project.threads.find((t) => t.agentId === instructor.id);
if (!thread) return;
const labels = taskDividerLabels(project.language);
const left = args.completedTitle
? `${labels.completedPrefix}${args.completedTitle}`
: labels.completedFallback;
const right = args.nextTitle ? `${labels.nextPrefix}${args.nextTitle}` : labels.nextFallback;
const content = `${TASK_DIVIDER_PREFIX}${left} ${right}`;
if (thread.messages.some((m) => m.content === content)) return;
const message: PBLChatMessage = {
id: 'msg_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6),
agentId: instructor.id,
roleType: 'instructor',
content,
ts: new Date().toISOString(),
microtaskId: args.nextMicrotaskId,
};
thread.messages.push(message);
appendRuntimeEvent(project, {
id: mintRuntimeEventId(),
kind: 'message_created',
actorType: 'agent',
actorRoleId: instructor.id,
messageId: message.id,
threadId: thread.agentId,
ts: message.ts,
microtaskId: message.microtaskId,
milestoneId: milestoneIdForMicrotask(project, message.microtaskId),
});
}
function taskDividerLabels(language: string | undefined): {
completedPrefix: string;
completedFallback: string;
nextPrefix: string;
nextFallback: string;
} {
switch (language) {
case 'zh-CN':
return {
completedPrefix: '任务完成:',
completedFallback: '当前任务已完成',
nextPrefix: '开始下一任务:',
nextFallback: '开始下一任务',
};
case 'zh-TW':
return {
completedPrefix: '任務完成:',
completedFallback: '目前任務已完成',
nextPrefix: '開始下一任務:',
nextFallback: '開始下一任務',
};
case 'ja-JP':
return {
completedPrefix: 'タスク完了: ',
completedFallback: '現在のタスクが完了しました',
nextPrefix: '次のタスクへ: ',
nextFallback: '次のタスクへ',
};
case 'ru-RU':
return {
completedPrefix: 'Задача завершена: ',
completedFallback: 'Текущая задача завершена',
nextPrefix: 'Следующая задача: ',
nextFallback: 'Следующая задача',
};
case 'ar-SA':
return {
completedPrefix: 'اكتملت المهمة: ',
completedFallback: 'اكتملت المهمة الحالية',
nextPrefix: 'بدء المهمة التالية: ',
nextFallback: 'بدء المهمة التالية',
};
default:
return {
completedPrefix: 'Task complete: ',
completedFallback: 'Current task complete',
nextPrefix: 'Next task: ',
nextFallback: 'Next task',
};
}
}
function appendMilestoneDividerMessage(
project: PBLProjectV2,
handover: PBLHandover,
microtaskId: string | undefined,
): void {
// SCENARIO ONLY: never add an in-chat stage-advance divider for a role-play
// project. The three acts (prep → roleplay → wrapup) are already made legible
// by the left roadmap's stage labels and, on entering wrapup, by the
// collapsible "role-play history" block embedded in the Instructor feed — so
// a "[stage advance]" divider in the conversation is redundant noise (it used
// to appear when crossing into wrapup). Ordinary projects keep their
// Instructor-thread milestone divider exactly as before.
if (project.scenario) return;
const content = `${MILESTONE_DIVIDER_PREFIX}${milestoneDividerLabel(project.language, handover)}`;
const instructor = project.roles.find((r) => r.type === 'instructor');
if (!instructor) return;
const thread = project.threads.find((t) => t.agentId === instructor.id);
if (!thread) return;
if (thread.messages.some((m) => m.content === content)) return;
const message: PBLChatMessage = {
id: 'msg_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6),
agentId: instructor.id,
roleType: 'instructor',
content,
ts: new Date().toISOString(),
microtaskId,
};
thread.messages.push(message);
appendRuntimeEvent(project, {
id: mintRuntimeEventId(),
kind: 'message_created',
actorType: 'agent',
actorRoleId: instructor.id,
messageId: message.id,
threadId: thread.agentId,
ts: message.ts,
microtaskId: message.microtaskId,
milestoneId: milestoneIdForMicrotask(project, message.microtaskId),
});
}
function milestoneDividerLabel(language: string | undefined, handover: PBLHandover): string {
const transition = `${handover.completedMilestoneTitle}${handover.nextMilestoneTitle}`;
switch (language) {
case 'zh-CN':
return `阶段推进:${transition}`;
case 'zh-TW':
return `階段推進:${transition}`;
case 'ja-JP':
return `ステージ進行: ${transition}`;
case 'ru-RU':
return `Продвижение этапа: ${transition}`;
case 'ar-SA':
return `تقدم المرحلة: ${transition}`;
default:
return `Stage progression: ${transition}`;
}
}