Files
openmaic/OpenMAIC/components/scene-renderers/pbl/v2/apply-instructor-event.ts
2026-08-16 14:58:47 +08:00

178 lines
7.1 KiB
TypeScript

/**
* PBL v2 — Apply one SSE event to a working project clone.
*
* Pure (no React, no fetch): given an incoming event and a project
* clone, return the (possibly mutated) clone plus push the token
* delta into the live draft state setter.
*
* Extracted from the hook to keep the streaming loop tight and to
* allow direct unit testing of patch application logic.
*/
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
import { applyAdvanceProjectPatch } from '@/lib/pbl/v2/operations/runtime/advance-patch';
import { capEngagementEvents } from '@/lib/pbl/v2/operations/kernel/engagement';
import { PBL_SIMULATOR_AGENT_ID } from '@/lib/pbl/v2/operations/kernel/progress';
import {
appendProficiencyUpdatedRuntimeEvent,
appendRuntimeEvent,
milestoneIdForMicrotask,
mintRuntimeEventId,
} from '@/lib/pbl/v2/operations/kernel/runtime-events';
import { isStandaloneDividerMessage, stripEmbeddedDividerMarkers } from './protocol-markers';
import type { PBLChatMessage, PBLRuntimeActorType } from '@/lib/pbl/v2/types';
function cleanProtocolMarkersFromMessage<M extends { content: string }>(message: M): M {
const trimmed = message.content.trimStart();
if (isStandaloneDividerMessage(trimmed)) {
return message;
}
const cleaned = stripEmbeddedDividerMarkers(message.content);
if (cleaned === message.content) return message;
return { ...message, content: cleaned };
}
function actorTypeForMessage(message: PBLChatMessage): PBLRuntimeActorType {
if (message.roleType === 'user') return 'user';
if (message.roleType === 'system') return 'system';
return 'agent';
}
export function applyInstructorEvent(
event: PBLSSEEvent,
project: PBLProjectV2,
setDraft: (fn: (prev: string) => string) => void,
): PBLProjectV2 {
switch (event.type) {
case 'token':
setDraft((prev) => prev + event.delta);
return project;
case 'reset_draft':
// An advancing turn discarded its streamed prose in favour of the
// isolated wrap-up; drop the live draft so a leaked next-task preview
// doesn't linger until the wrap-up message patch arrives.
setDraft(() => '');
return project;
case 'project_patch': {
const next = structuredClone(project);
const patch = event.patch;
switch (patch.kind) {
case 'message': {
// SCENARIO ONLY routing: simulator-spoken lines and neutral
// system narration land on the dedicated Simulator thread;
// everything else (instructor / divider) stays on the
// Instructor thread exactly as before. Ordinary projects only
// ever produce instructor/user messages, so this is a no-op
// for them (the simulator thread never exists).
const toSimulator =
patch.message.roleType === 'simulator' || patch.message.roleType === 'system';
const thread = toSimulator
? next.threads.find((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)
: next.threads.find((t) => {
const r = next.roles.find((r) => r.id === t.agentId);
return r?.type === 'instructor';
});
const message = cleanProtocolMarkersFromMessage(patch.message);
if (thread && !thread.messages.some((m) => m.id === message.id) && message.content) {
thread.messages.push(message);
const actorType = actorTypeForMessage(message);
appendRuntimeEvent(next, {
id: mintRuntimeEventId(),
kind: 'message_created',
actorType,
actorRoleId: actorType === 'agent' ? message.agentId : undefined,
messageId: message.id,
threadId: thread.agentId,
ts: message.ts,
microtaskId: message.microtaskId,
milestoneId: milestoneIdForMicrotask(next, message.microtaskId),
});
}
// A message patch is the committed version of the live
// assistant draft. Clear the draft immediately so the UI
// never shows the same response as both a saved bubble and
// an "typing" bubble while follow-up patches arrive.
setDraft(() => '');
break;
}
case 'engagement_event': {
const event = patch.event ?? {
id: 'evt_local_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6),
kind: patch.eventKind as PBLProjectV2['engagementEvents'][number]['kind'],
microtaskId: patch.microtaskId,
milestoneId: patch.milestoneId,
ts: patch.ts ?? new Date().toISOString(),
payload: patch.payload,
};
if (!next.engagementEvents.some((existing) => existing.id === event.id)) {
next.engagementEvents.push(event);
capEngagementEvents(next);
}
break;
}
case 'advance': {
applyAdvanceProjectPatch(next, patch);
break;
}
case 'handover': {
next.pendingHandover = patch.handover;
appendRuntimeEvent(next, {
id: mintRuntimeEventId(),
kind: 'handover_staged',
actorType: 'system',
completedMilestoneId: patch.handover.completedMilestoneId,
nextMilestoneId: patch.handover.nextMilestoneId,
nextMicrotaskId: patch.handover.nextTaskId,
ts: new Date().toISOString(),
milestoneId: patch.handover.completedMilestoneId,
microtaskId: patch.handover.nextTaskId,
});
break;
}
case 'evaluation': {
next.evaluations.push(patch.evaluation);
appendRuntimeEvent(next, {
id: mintRuntimeEventId(),
kind: 'evaluation_created',
actorType: 'system',
evaluationId: patch.evaluation.id,
ts: patch.evaluation.createdAt,
microtaskId: patch.evaluation.microtaskId,
milestoneId:
patch.evaluation.milestoneId ??
milestoneIdForMicrotask(next, patch.evaluation.microtaskId),
});
// Evaluation patches are the committed version of the live
// evaluator draft. Clear it here for the same reason we
// clear message drafts above: the learner should not see a
// streamed preview duplicated under the finished card.
setDraft(() => '');
break;
}
case 'proficiency': {
// Adaptive engine state update. Replace the assessment
// wholesale and mirror the tier onto the legacy
// `proficiency` field so older consumers (tier-guidance
// block, dev tooling) stay in sync. The chat does NOT
// render this — it's only consumed by the dev badge.
next.proficiencyAssessment = patch.assessment;
next.proficiency = patch.assessment.tier;
appendProficiencyUpdatedRuntimeEvent(next);
break;
}
}
next.updatedAt = new Date().toISOString();
return next;
}
case 'error':
case 'tool_call':
case 'done':
default:
return project;
}
}