项目文件夹

文件
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 行
35 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* 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}`;
}
}