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:
amosblomqvist
2026-09-12 05:14:40 +03:00
parent 4ba594bab5
commit 740293c361
11 changed files with 319 additions and 67 deletions
+7 -6
View File
@@ -27,13 +27,14 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined;
// The abort steer (2026-09 dock semantics): no artifact part, only the
// metadata marker — a quiet chip, mirroring the complete-badge idiom.
// The abort steer (2026-09 dock semantics): its message-type part is
// data-subagent-aborted (the custom-message scheme — the part, not
// metadata, is the marker) — a quiet chip, mirroring the complete-badge.
if (!data) {
const meta = (
message.metadata as { subagentResult?: { aborted?: boolean; kind?: string } } | undefined
)?.subagentResult;
if (meta?.aborted) return <AbortedBadge kind={meta.kind ?? "job"} />;
const aborted = message.parts.find((p) => p.type === "data-subagent-aborted") as
| { data?: { kind?: string } }
| undefined;
if (aborted?.data) return <AbortedBadge kind={aborted.data.kind ?? "job"} />;
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
A sub-agent finished, but its result could not be displayed.
+76 -14
View File
@@ -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)} />
+30 -7
View File
@@ -123,16 +123,39 @@ export interface TurnUsage {
* block renders current node + direct parents + direct children in its own
* small container, the current node glowing — clearly a fragment, not the
* whole plan. Clicking either opens the full plan in a popup. */
/** The learner's plan-acceptance marker (docs/probe-teach-split.md §4): a
* data part riding the accept-click user message. It is THE plan→teach
* phase seam — phaseAt3 reads it from the transcript;
* convertToModelMessages drops data parts, so the teacher only sees the
* message's text; the marker is for derivation and the UI. */
export interface PlanAcceptedData {
/** ISO timestamp of the accept click. */
/** The learner's plan verdict (docs/probe-teach-split.md §4): a data part
* riding its own user message — the ONLY thing on that message. It is THE
* plan→teach phase seam (phaseAt3 reads accepted from the transcript), and
* both verdicts are real transcript events: accept sanctions teaching,
* decline asks for revision. The part is the message TYPE (the "custom
* message" scheme, 2026-09-12): the message carries no human-authored text,
* the model-facing wording is owned by the adapter (record-adapter.ts
* flattens it to VERDICT_TEXT below), and the UI renders the verdict
* marker — never a user bubble. A learner who also typed a note sends the
* note as its own real user message first; the verdict message follows. */
export interface PlanVerdictData {
/** true = accept (sanction to teach); false = decline (revise the plan). */
accepted: boolean;
/** ISO timestamp of the click. */
at: string;
}
/** The model-facing text of a verdict message — single-sourced here so the
* adapter, the tests, and reality can't drift apart. */
export function verdictText(accepted: boolean): string {
return accepted
? "The learner accepted the plan — begin teaching."
: "The learner would like the plan revised before teaching begins.";
}
/** The model-facing text of the probe→teach handoff wake (the custom-message
* scheme): the wake message carries only the data-probe-handoff part, and
* the adapter flattens it to THIS. Single-sourced so the prober's seam, the
* tests, and reality can't drift apart. */
export const HANDOFF_TEXT =
"The probe is concluded — the learner assessment is recorded. " +
"Author the session's plan map now (call plan_init) and present it, then begin teaching.";
export interface PlanBlockData {
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
* FLOW once, at authoring. The neighborhood fragments are retired —
+19 -12
View File
@@ -256,7 +256,10 @@ export interface SubagentSteerMessage extends UIMessage {
export interface AbortSteerMessage extends UIMessage {
role: "user";
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
parts: [{ type: "text"; text: string }];
parts: [
{ type: "text"; text: string },
{ type: "data-subagent-aborted"; data: SubagentResultMeta & { aborted: true } },
];
}
/** Anything the wake pipeline may queue and deliver. */
@@ -324,13 +327,17 @@ export function buildSubagentSteer(
export function buildAbortSteer(
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
): AbortSteerMessage {
const meta = { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true } as const;
return {
id: `steer_${job.id}`,
role: "user",
metadata: {
subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true },
},
parts: [{ type: "text", text: abortText(job.kind) }],
metadata: { subagentResult: meta },
parts: [
{ type: "text", text: abortText(job.kind) },
// The message-type marker (the custom-message scheme): the UI
// renders the abort badge off this part, not off metadata.
{ type: "data-subagent-aborted", data: meta },
],
};
}
@@ -356,7 +363,12 @@ export interface ProbeHandoffSteerMessage extends UIMessage {
id: `steer_probe_${string}`;
role: "user";
metadata: { probeHandoff: { chatId: string } };
parts: [{ type: "text"; text: string }];
/** The custom-message scheme (2026-09-12): NO text part — the wake is
* scaffolding, not speech. The model-facing instruction is owned by the
* adapter (record-adapter.ts flattens this part to HANDOFF_TEXT in
* core/contracts/chat.ts); the UI renders the quiet system line off the
* part. Nothing human-looking is persisted, ever. */
parts: [{ type: "data-probe-handoff"; data: { chatId: string } }];
}
export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage {
@@ -364,12 +376,7 @@ export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage
id: `steer_probe_${chatId}`,
role: "user",
metadata: { probeHandoff: { chatId } },
parts: [
{
type: "text",
text: "The probe is concluded — the learner assessment is recorded. Author the session's plan map now (call plan_init) and present it, then begin teaching.",
},
],
parts: [{ type: "data-probe-handoff", data: { chatId } }],
};
}
+12 -7
View File
@@ -6,9 +6,10 @@
* probe — until the first message containing a `tool-probe_done` part
* (the prober concludes; the assessment rides the tool part)
* plan — probe concluded, until the learner ACCEPTS the presented
* plan (a `data-plan-accepted` part on the accept button's
* user message — the explicit verdict gate; plan_init itself
* is only the PRESENTATION and moves no phase)
* plan (a `data-verdict` part with accepted:true on the verdict
* button's own user message — the explicit verdict gate;
* plan_init itself is only the PRESENTATION and moves no
* phase; a declined verdict stays in plan)
* teach — teaching is sanctioned
*
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
@@ -49,14 +50,18 @@ function advance(
if (!parts) return phase;
for (const part of parts) {
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
// plan → teach only via the learner's plan acceptance
// (data-plan-accepted). A marker out of order advances nothing — the
// plan → teach only via the learner's plan acceptance (data-verdict
// with accepted:true). A marker out of order advances nothing — the
// chain never skips. plan_init PRESENTS the map; only the verdict
// (the accept button's marker part) sanctions teaching.
// sanctions teaching (a decline stays in plan).
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
phase = "plan";
}
if (phase === "plan" && part.type === "data-plan-accepted") {
if (
phase === "plan" &&
part.type === "data-verdict" &&
(part as { data?: { accepted?: unknown } }).data?.accepted === true
) {
phase = "teach";
}
}
+11 -4
View File
@@ -1,12 +1,13 @@
import type { UIMessage } from "ai";
import type {
DemoToolInput,
PlanAcceptedData,
PlanVerdictData,
PlanBlockData,
ProbeDoneInput,
ProbeDoneResult,
ResearchToolInput,
SubagentArtifactData,
SubagentResultMeta,
VisualToolInput,
} from "./contracts.ts";
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
@@ -38,9 +39,15 @@ export type TeacherUIDataParts = {
"subagent-artifact": SubagentArtifactData;
/** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */
"plan-block": PlanBlockData;
/** The plan-acceptance marker: the accept button's user message carries
* it — the plan→teach phase seam (docs/probe-teach-split.md §4). */
"plan-accepted": PlanAcceptedData;
/** The learner's plan verdict (accept/decline): its own user message,
* carrying ONLY this part — the plan→teach phase seam (docs/probe-
* teach-split.md §4, the custom-message scheme 2026-09-12). */
verdict: PlanVerdictData;
/** The probe→teach handoff wake: the wake pipeline's steer message,
* carrying ONLY this part (the model-facing text lives in the adapter). */
"probe-handoff": { chatId: string };
/** The abort steer's message-type marker (the abort badge renders off it). */
"subagent-aborted": SubagentResultMeta & { aborted: true };
};
export type TeacherUIMessage = UIMessage<unknown, TeacherUIDataParts, TeacherUITools>;
+31 -4
View File
@@ -1,6 +1,7 @@
import type { UIMessage } from "ai";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
/**
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The
@@ -8,9 +9,15 @@ import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-wor
* fresh agent from it (the stateless per-turn decision). This replaces the
* SDK's convertToModelMessages with an owned, explicit mapping:
*
* - user text parts → UserMessage text content; data parts are dropped
* (the plan-accept seam rides its text twin — the client always sends
* "The plan is accepted — begin teaching." alongside the marker).
* - user text parts → UserMessage text content.
* - SYSTEM DATA PARTS (the custom-message scheme, 2026-09-12): the wake and
* verdict messages carry ONLY a marker part — no human text exists to
* drop, so the ADAPTER flattens them: data-verdict → verdictText(),
* data-probe-handoff → HANDOFF_TEXT (core/contracts/chat.ts owns both —
* single source, so the model-facing wording can't drift from the seam).
* Other data parts (artifacts, plan blocks) are dropped as before: they
* are UI history; the steer briefs ride their text twin (payload, not
* boilerplate — those texts are content the sub-agent produced).
* - assistant text/reasoning/tool parts → one AssistantMessage whose
* content blocks mirror the part order; tool parts become ToolCall
* blocks (output-error parts keep their rawInput).
@@ -46,6 +53,18 @@ function zeroUsage() {
};
}
/** The model-facing text of a marker-only user message, or null when the
* message has no model face at all (UI-only data parts). */
function systemTextOf(parts: ReadonlyArray<{ type: string; data?: unknown }>): string | null {
for (const part of parts) {
if (part.type === "data-verdict") {
return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true);
}
if (part.type === "data-probe-handoff") return HANDOFF_TEXT;
}
return null;
}
export function recordToPiMessages(messages: UIMessage[]): Message[] {
const out: AgentMessage[] = [];
@@ -55,7 +74,15 @@ export function recordToPiMessages(messages: UIMessage[]): Message[] {
.filter((p) => p.type === "text")
.map((p) => textOf(p as { text?: string }))
.filter((t) => t.length > 0);
if (texts.length === 0) continue; // pure data-part marker, no text
// The custom-message scheme: a marker-only message (verdict,
// handoff) flattens to its canonical model-facing text — the
// wording lives in the adapter's reach (core contracts), never
// authored as fake human speech in the transcript.
if (texts.length === 0) {
const flat = systemTextOf(message.parts);
if (flat === null) continue; // pure UI data part, no model face
texts.push(flat);
}
out.push({
role: "user",
content: texts.map((text) => ({ type: "text" as const, text })),
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+88
View File
@@ -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");
});
});