feat: custom message types for system turns — wake and verdict messages
The custom-message scheme (user-decided 2026-09-12): system-authored
user-role messages are NOT human messages — they carry marker data parts
only, the adapter owns their model-facing wording, and the UI renders
them under control. Nothing fake is ever persisted as human speech.
The inventory (all migrated):
- probe→teacher handoff wake: was a text-bearing user message rendering
as a fake user bubble; now data-probe-handoff marker-only — the
adapter flattens it to HANDOFF_TEXT (core/contracts/chat.ts), the UI
renders a quiet 'Preparing your plan…' system line.
- plan accept/decline: was boilerplate text ('The plan is accepted…',
'I'd like the plan revised…') masquerading as learner speech — the
decline wasn't even marked; now data-verdict {accepted, at} on its own
message, adapter flattens to verdictText(), UI renders the quiet
verdict marker ('✓ Plan accepted' / '↻ Plan revision requested').
sendVerdict sends TWO REAL MESSAGES when the learner typed a note
(the note as a genuine user message, then the verdict) in ONE turn
via setMessages + sendMessage() — no 409 dance, no double teacher turn.
- sub-agent steers: the abort steer gains its data-subagent-aborted
marker part; the UI keys steer rendering off PARTS (artifact/aborted),
not metadata — metadata stays as pump mechanics only.
- record-adapter: systemTextOf() flattens marker-only messages; steers
keep their text (payload — briefs are content, not boilerplate).
phases.ts: plan→teach advances on data-verdict{accepted:true} only;
learner text alone can never sanction teaching (pinned). data-plan-
accepted retired, no back-compat (user-decided: wiped-data doctrine).
Pinned: record-adapter.test.ts (verdict/handoff flattening, note+verdict
order, steer payload kept, UI-only parts skipped), phases.test.ts
(verdict marker semantics, out-of-order, text-never-fakes), jobs.test.ts
(abort marker part), demos.test.tsx (badge renders off the part, not
metadata).
Live GLM smoke (disposable driver, deleted): probe→conclude→wake→plan→
decline-with-note (revised, still plan)→accept-with-note (teach) — ALL
CHECKS PASS, including marker-only handoff/verdict on the wire and zero
fake human speech in the transcript.
Gates: tsc, lint, build, 335/335.
This commit is contained in:
+76
-14
@@ -52,6 +52,20 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
|
||||
|
||||
/** A wake-pipeline steer message: user-role delivery whose metadata marks it
|
||||
* as a sub-agent result — rendered as an artifact card, never a bubble. */
|
||||
/** The quiet system line (custom-message scheme rendering): centered, muted,
|
||||
* small — the transcript's voice for events that are not speech (wake
|
||||
* scaffolding, the learner's plan verdict). */
|
||||
function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex items-center justify-center gap-1.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isSubagentSteer(m: TeacherUIMessage): boolean {
|
||||
return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null;
|
||||
}
|
||||
@@ -194,22 +208,39 @@ export function Chat({
|
||||
(accepted: boolean) => {
|
||||
const note = text.trim();
|
||||
setText("");
|
||||
if (accepted) {
|
||||
void sendMessage({
|
||||
parts: [
|
||||
{ type: "text", text: note || "The plan is accepted — begin teaching." },
|
||||
{
|
||||
type: "data-plan-accepted",
|
||||
id: `pa_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||
data: { at: new Date().toISOString() },
|
||||
},
|
||||
],
|
||||
// TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided
|
||||
// 2026-09-12): a typed note is the learner's own user message, and the
|
||||
// verdict is its own custom message — marker part ONLY, no human-
|
||||
// authored text (the model-facing wording lives in the adapter:
|
||||
// record-adapter flattens it; nothing fake is persisted as speech).
|
||||
// setMessages + sendMessage() (no args) fires ONE request carrying
|
||||
// both — the same single-turn mechanism syncFromServer already uses,
|
||||
// so no 409 dance and no double teacher turn.
|
||||
const stamp = Date.now().toString(36);
|
||||
const rnd = crypto.randomUUID().slice(0, 8);
|
||||
const appended: TeacherUIMessage[] = [];
|
||||
if (note) {
|
||||
appended.push({
|
||||
id: `msg_${stamp}_${rnd}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: note }],
|
||||
});
|
||||
} else {
|
||||
void sendMessage({ text: note || "I'd like the plan revised before we begin." });
|
||||
}
|
||||
appended.push({
|
||||
id: `verdict_${stamp}_${rnd}`,
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "data-verdict",
|
||||
id: `vd_${stamp}_${rnd}`,
|
||||
data: { accepted, at: new Date().toISOString() },
|
||||
},
|
||||
],
|
||||
});
|
||||
setMessages([...messages, ...appended]);
|
||||
void sendMessage();
|
||||
},
|
||||
[text, sendMessage],
|
||||
[text, messages, setMessages, sendMessage],
|
||||
);
|
||||
|
||||
// ── Session usage (wave-4 stats bar) ──
|
||||
@@ -444,7 +475,38 @@ export function Chat({
|
||||
// PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene)
|
||||
// can still hold empty ids — the index keeps keys unique for them.
|
||||
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
|
||||
) : (
|
||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
|
||||
// The probe→teach handoff wake (custom-message scheme): quiet
|
||||
// system line, never a user bubble — the wake is scaffolding,
|
||||
// the "Planning" divider marks the seam, and the model-facing
|
||||
// instruction lives in the adapter.
|
||||
<SystemLine key={m.id || `m-${mi}`} testId="probe-handoff">
|
||||
Preparing your plan…
|
||||
</SystemLine>
|
||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-verdict") ? (
|
||||
// The learner's plan verdict (custom-message scheme): its own
|
||||
// message, the quiet marker — the accept is the phase seam, a
|
||||
// decline asks for revision. Never a user bubble: the model-
|
||||
// facing wording is the adapter's, and this is the learner's
|
||||
// ACT, not their words (a typed note, if any, rendered above
|
||||
// as its own real message).
|
||||
<SystemLine
|
||||
key={m.id || `m-${mi}`}
|
||||
testId={
|
||||
(m.parts.find((p) => p.type === "data-verdict") as
|
||||
| { data?: { accepted?: boolean } }
|
||||
| undefined)?.data?.accepted
|
||||
? "verdict-accepted"
|
||||
: "verdict-declined"
|
||||
}
|
||||
>
|
||||
{(m.parts.find((p) => p.type === "data-verdict") as
|
||||
| { data?: { accepted?: boolean } }
|
||||
| undefined)?.data?.accepted
|
||||
? "✓ Plan accepted"
|
||||
: "↻ Plan revision requested"}
|
||||
</SystemLine>
|
||||
) : (
|
||||
<Fragment key={m.id || `m-${mi}`}>
|
||||
{mi > 0 && phases[mi] !== phases[mi - 1] && (
|
||||
<PhaseDivider phase={phases[mi]!} nodeCount={planNodeCount(m)} />
|
||||
|
||||
Reference in New Issue
Block a user