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:
@@ -328,16 +328,20 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
||||
});
|
||||
|
||||
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics)", () => {
|
||||
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics; the message type is the marker PART)", () => {
|
||||
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
|
||||
assert.equal(steer.metadata.subagentResult.aborted, true);
|
||||
assert.equal(steer.parts.length, 1, "no artifact part on an abort");
|
||||
assert.equal(steer.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /job-aborted-badge/);
|
||||
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
|
||||
assert.match(html, /aborted by the learner/);
|
||||
// the teacher-facing protocol text never renders for the learner
|
||||
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
|
||||
// the badge renders off the PART, not metadata (the custom-message scheme)
|
||||
const noMeta = { ...steer, metadata: {} } as typeof steer;
|
||||
const htmlNoMeta = renderToStaticMarkup(<SubagentSteerCard message={noMeta as never} />);
|
||||
assert.match(htmlNoMeta, /job-aborted-badge/, "metadata is pump mechanics, never the render seam");
|
||||
});
|
||||
|
||||
it("renders a quiet Research-complete one-liner whose collapsed <details> carries the full brief", () => {
|
||||
|
||||
+9
-3
@@ -238,14 +238,19 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
}, "lastStep always mirrors the LATEST call, never the sums");
|
||||
});
|
||||
|
||||
it("buildAbortSteer: metadata-aborted, text-only (no artifact), same deterministic id space as the result steer", () => {
|
||||
it("buildAbortSteer: teacher-facing text + the data-subagent-aborted marker part (the message type), same deterministic id space as the result steer", () => {
|
||||
const steer = buildAbortSteer({ id: "job_c", kind: "demo", toolCallId: "call-c" });
|
||||
assert.equal(steer.id, "steer_job_c", "same id space — steerDelivered treats either as delivered");
|
||||
assert.equal(steer.role, "user");
|
||||
assert.equal(steer.metadata.subagentResult.aborted, true);
|
||||
assert.equal(steer.metadata.subagentResult.jobId, "job_c");
|
||||
assert.equal(steer.parts.length, 1);
|
||||
assert.equal(steer.parts.length, 2);
|
||||
assert.ok(steer.parts[0]!.type === "text" && /aborted/.test(steer.parts[0].text));
|
||||
// The marker PART is the message type (the custom-message scheme): the
|
||||
// UI renders the abort badge off it — metadata stays for pump mechanics.
|
||||
const marker = steer.parts[1]!;
|
||||
assert.equal(marker.type, "data-subagent-aborted");
|
||||
assert.deepEqual(marker.data, { jobId: "job_c", kind: "demo", toolCallId: "call-c", aborted: true });
|
||||
});
|
||||
|
||||
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
|
||||
@@ -996,7 +1001,8 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
const last = fired[0]!;
|
||||
assert.equal((last.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted, true);
|
||||
assert.equal(last.id, `steer_${jobId}`);
|
||||
assert.equal(last.parts.length, 1, "text only — no artifact part");
|
||||
assert.equal(last.parts.length, 2, "teacher-facing text + the data-subagent-aborted marker part — no artifact");
|
||||
assert.equal(last.parts[1]!.type, "data-subagent-aborted");
|
||||
// aborted is terminal: the wake completing must not flip it
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "error", "terminal after the wake");
|
||||
} finally {
|
||||
|
||||
+30
-8
@@ -2,7 +2,7 @@
|
||||
* phases — the event-driven phase chain (docs/probe-teach-split.md §4).
|
||||
*
|
||||
* probe ends at the first CONCLUDED probe_done (output-available); planning
|
||||
* ends at the LEARNER'S plan acceptance (a data-plan-accepted part on the
|
||||
* ends at the LEARNER'S plan acceptance (a data-verdict part with accepted:true
|
||||
* accept button's user message — plan_init only PRESENTS the map). No
|
||||
* stored-tree seam: the phase is a pure fold over the transcript, so it can
|
||||
* never stick (the old currentNodeId-based phaseOfTree read a field nothing
|
||||
@@ -34,18 +34,24 @@ function assistantTool(
|
||||
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||
|
||||
/** The accept button's user message (text + marker data part). */
|
||||
function planAccepted(): UIMessage {
|
||||
/** The verdict button's user message (the custom-message scheme): its own
|
||||
* message carrying ONLY the data-verdict marker part — no human-authored
|
||||
* text; the model-facing wording is the adapter's (record-adapter.ts). */
|
||||
function verdict(accepted: boolean): UIMessage {
|
||||
return {
|
||||
id: `u_accept_${Math.random().toString(36).slice(2, 6)}`,
|
||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "user",
|
||||
parts: [
|
||||
{ type: "text", text: "The plan is accepted — begin teaching." },
|
||||
{ type: "data-plan-accepted", id: "pa_1", data: { at: "2026-09-11T00:00:00Z" } } as never,
|
||||
{ type: "data-verdict", id: "vd_1", data: { accepted, at: "2026-09-12T00:00:00Z" } } as never,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The accept button's message. */
|
||||
function planAccepted(): UIMessage {
|
||||
return verdict(true);
|
||||
}
|
||||
|
||||
test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEPTS; teach after", () => {
|
||||
const probe = phaseAt3([user("teach me calculus"), assistantTool("quiz", "output-available")]);
|
||||
assert.equal(probe, "probe", "quiz answers alone never end the probe");
|
||||
@@ -75,10 +81,10 @@ test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEP
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
user("revise it"),
|
||||
verdict(false),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
]);
|
||||
assert.equal(declined, "plan", "a declined plan stays in planning until a later acceptance");
|
||||
assert.equal(declined, "plan", "a DECLINED verdict is a real event but stays in planning until a later acceptance");
|
||||
|
||||
const failedPlan = phaseAt3([
|
||||
user("teach me"),
|
||||
@@ -89,6 +95,22 @@ test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEP
|
||||
assert.equal(failedPlan, "teach", "acceptance sanctions teaching even after a failed init attempt — the verdict is his");
|
||||
});
|
||||
|
||||
test("phaseAt3: the verdict marker is explicit — anonymous learner text never sanctions teaching", () => {
|
||||
const plainText = phaseAt3([
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
user("The plan is accepted — begin teaching."), // words alone, no marker
|
||||
]);
|
||||
assert.equal(plainText, "plan", "only the data-verdict part is the seam — text can never fake it");
|
||||
|
||||
const acceptOutOfOrder = phaseAt3([
|
||||
user("teach me"),
|
||||
verdict(true), // before any probe_done: out of order, advances nothing
|
||||
]);
|
||||
assert.equal(acceptOutOfOrder, "probe", "the chain never skips");
|
||||
});
|
||||
|
||||
test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keeps the probe open", () => {
|
||||
const still = phaseAt3([user("teach me"), assistantTool("probe_done", "input-available")]);
|
||||
assert.equal(still, "probe");
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Unit tests — the record→pi adapter's SYSTEM MESSAGE flattening (the
|
||||
* custom-message scheme, user-decided 2026-09-12).
|
||||
*
|
||||
* Wake and verdict messages are user-role messages carrying ONLY a marker
|
||||
* data part — no human text exists. The adapter owns their model-facing
|
||||
* wording (flattens the marker to the canonical text from core contracts):
|
||||
* the transcript never persists fake human speech, and the wording can't
|
||||
* drift between the seam, the tests, and reality.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
||||
import { HANDOFF_TEXT, verdictText } from "../src/core/contracts/chat.ts";
|
||||
import { buildAbortSteer, buildProbeHandoffSteer } from "../src/core/contracts/jobs.ts";
|
||||
|
||||
function userText(text: string): UIMessage {
|
||||
return { id: `u_${text.length}`, role: "user", parts: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function userParts(parts: unknown[]): UIMessage {
|
||||
return { id: "u_sys", role: "user", parts: parts as UIMessage["parts"] };
|
||||
}
|
||||
|
||||
const texts = (msgs: ReturnType<typeof recordToPiMessages>): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const m of msgs) {
|
||||
if (m.role === "user") {
|
||||
for (const c of m.content) {
|
||||
if ((c as { type?: string }).type === "text") out.push((c as { text: string }).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe("record-adapter: system message flattening", () => {
|
||||
it("an ACCEPT verdict flattens to the canonical model-facing text (no human speech persisted)", () => {
|
||||
const out = recordToPiMessages([
|
||||
userText("teach me"),
|
||||
userParts([{ type: "data-verdict", id: "vd_1", data: { accepted: true, at: "…" } }]),
|
||||
]);
|
||||
assert.deepEqual(texts(out), ["teach me", verdictText(true)]);
|
||||
assert.equal(texts(out)[1], "The learner accepted the plan — begin teaching.");
|
||||
});
|
||||
|
||||
it("a DECLINE verdict flattens to the canonical revision text", () => {
|
||||
const out = recordToPiMessages([
|
||||
userParts([{ type: "data-verdict", id: "vd_2", data: { accepted: false, at: "…" } }]),
|
||||
]);
|
||||
assert.deepEqual(texts(out), [verdictText(false)]);
|
||||
assert.match(texts(out)[0]!, /revised/);
|
||||
});
|
||||
|
||||
it("the probe handoff steer flattens to the canonical plan-authoring instruction", () => {
|
||||
const handoff = buildProbeHandoffSteer("chat_x");
|
||||
assert.equal(handoff.parts.length, 1, "the handoff is marker-ONLY — no text part");
|
||||
assert.equal(handoff.parts[0]!.type, "data-probe-handoff");
|
||||
const out = recordToPiMessages([handoff]);
|
||||
assert.deepEqual(texts(out), [HANDOFF_TEXT]);
|
||||
assert.match(texts(out)[0]!, /plan_init/);
|
||||
});
|
||||
|
||||
it("note + verdict (the two-message accept) reach the model as two user texts, in order", () => {
|
||||
const out = recordToPiMessages([
|
||||
userText("Start with the basics, skip the history."),
|
||||
userParts([{ type: "data-verdict", id: "vd_3", data: { accepted: true, at: "…" } }]),
|
||||
]);
|
||||
assert.deepEqual(texts(out), ["Start with the basics, skip the history.", verdictText(true)]);
|
||||
});
|
||||
|
||||
it("sub-agent steers keep their text (payload, not boilerplate): the artifact part adds nothing", () => {
|
||||
const abort = buildAbortSteer({ id: "j1", kind: "research", toolCallId: "c1" });
|
||||
const out = recordToPiMessages([abort]);
|
||||
const got = texts(out);
|
||||
assert.equal(got.length, 1, "one user text — the marker part is UI-only");
|
||||
assert.match(got[0]!, /aborted/i);
|
||||
});
|
||||
|
||||
it("a pure UI data part with no system meaning still never reaches the model", () => {
|
||||
const out = recordToPiMessages([
|
||||
userParts([{ type: "data-plan-block", id: "pb", data: { kind: "full", at: "…", current: null, nodes: [], edges: [] } }]),
|
||||
]);
|
||||
assert.equal(out.length, 0, "no model face at all — skipped");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user