diff --git a/components/artifacts/steer-card.tsx b/components/artifacts/steer-card.tsx index 003710b..62e9415 100644 --- a/components/artifacts/steer-card.tsx +++ b/components/artifacts/steer-card.tsx @@ -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 ; + const aborted = message.parts.find((p) => p.type === "data-subagent-aborted") as + | { data?: { kind?: string } } + | undefined; + if (aborted?.data) return ; return (
A sub-agent finished, but its result could not be displayed. diff --git a/components/chat.tsx b/components/chat.tsx index 69b0f38..dee3282 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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 ( +
+ {children} +
+ ); +} + 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. - ) : ( + ) : 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. + + Preparing your plan… + + ) : 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). + 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"} + + ) : ( {mi > 0 && phases[mi] !== phases[mi - 1] && ( diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index e13ee57..8f8114e 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -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 — diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index 859055b..f617ebf 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -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, ): 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 } }], }; } diff --git a/src/core/phases.ts b/src/core/phases.ts index 6eefa9f..e2adc85 100644 --- a/src/core/phases.ts +++ b/src/core/phases.ts @@ -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"; } } diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts index 29ae7f0..807d103 100644 --- a/src/core/ui-message.ts +++ b/src/core/ui-message.ts @@ -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; diff --git a/src/server/pi/record-adapter.ts b/src/server/pi/record-adapter.ts index 0b0d849..029324d 100644 --- a/src/server/pi/record-adapter.ts +++ b/src/server/pi/record-adapter.ts @@ -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 })), diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index aef416a..0f3c0c6 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -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(); 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(); + assert.match(htmlNoMeta, /job-aborted-badge/, "metadata is pump mechanics, never the render seam"); }); it("renders a quiet Research-complete one-liner whose collapsed
carries the full brief", () => { diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index 644faae..1c3db76 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -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 { diff --git a/tests/phases.test.ts b/tests/phases.test.ts index 8931ff2..6a93431 100644 --- a/tests/phases.test.ts +++ b/tests/phases.test.ts @@ -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"); diff --git a/tests/record-adapter.test.ts b/tests/record-adapter.test.ts new file mode 100644 index 0000000..3eed091 --- /dev/null +++ b/tests/record-adapter.test.ts @@ -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): 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"); + }); +}); \ No newline at end of file