fix: verdict death — plan→teach seam inferred from first nodeId-tagged quiz
The Accept/Decline verdict gate is gone (owner ruling 2026-09-26): presenting the plan ends the teacher's turn; the learner's next message is a natural reply. Teaching begins at the first quiz call tagged with a nodeId (probe quizzes carry none) — both folds (view phaseAt3, native phaseAt3Lh) advance on the call itself, and the teach divider renders before that whole assistant block. No backwards compat: verdict markers, the verdict input event, optimistic appends, composer buttons, and projection all deleted; prompts and docs rewritten to the natural flow.
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Composer } from "../components/composer.tsx";
|
||||
|
||||
// The plan verdict gate (docs/probe-teach-split.md §4): while the presented
|
||||
// plan awaits the learner's word, the composer's send square becomes two
|
||||
// stacked buttons — green Accept plan, red Decline.
|
||||
|
||||
describe("Composer verdict buttons", () => {
|
||||
const noop = () => {};
|
||||
|
||||
it("no verdict: the normal send square renders", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Composer busy={false} value="" onChange={noop} onSend={noop} onStop={noop} canSend />,
|
||||
);
|
||||
assert.ok(!html.includes("plan-verdict-buttons"));
|
||||
assert.ok(html.includes('aria-label="Send"'));
|
||||
});
|
||||
|
||||
it("verdict pending: two stacked buttons replace the send square; stop square gone", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Composer
|
||||
busy={false}
|
||||
value=""
|
||||
onChange={noop}
|
||||
onSend={noop}
|
||||
onStop={noop}
|
||||
canSend
|
||||
verdict={{ onAccept: noop, onDecline: noop }}
|
||||
/>,
|
||||
);
|
||||
assert.ok(html.includes('data-testid="plan-verdict-buttons"'));
|
||||
assert.ok(html.includes("Accept plan"));
|
||||
assert.ok(html.includes("Decline"));
|
||||
assert.ok(!html.includes('aria-label="Send"'), "no send square while the verdict pends");
|
||||
});
|
||||
|
||||
it("busy: the stop square wins even while a verdict pends (no verdict mid-turn)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Composer
|
||||
busy
|
||||
value=""
|
||||
onChange={noop}
|
||||
onSend={noop}
|
||||
onStop={noop}
|
||||
canSend={false}
|
||||
verdict={{ onAccept: noop, onDecline: noop }}
|
||||
/>,
|
||||
);
|
||||
assert.ok(html.includes('aria-label="Stop"'));
|
||||
assert.ok(!html.includes("plan-verdict-buttons"));
|
||||
});
|
||||
});
|
||||
+90
-47
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* 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-verdict part with accepted:true
|
||||
* accept button's user message — plan_init only PRESENTS the map). No
|
||||
* probe ends at the first CONCLUDED probe_done (output-available); teaching
|
||||
* begins at the FIRST nodeId-tagged quiz CALL (the inferred seam, owner
|
||||
* ruling 2026-09-26 — the verdict death: presenting the plan ends the
|
||||
* teacher's turn, the learner's reply is natural text, and nothing flips
|
||||
* the phase until a question ABOUT a plan node is actually posed). 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
|
||||
* ever set).
|
||||
@@ -31,30 +33,21 @@ function assistantTool(
|
||||
};
|
||||
}
|
||||
|
||||
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||
|
||||
/** 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 toLlmMessages' (native-messages.ts). */
|
||||
function verdict(accepted: boolean): LhViewMessage {
|
||||
/** A quiz call with its parsed input — `nodeId` present iff tagged. */
|
||||
function assistantQuiz(input: Record<string, unknown> | undefined): LhViewMessage {
|
||||
return {
|
||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "user",
|
||||
parts: [
|
||||
{ type: "data-verdict", id: "vd_1", data: { accepted, at: "2026-09-12T00:00:00Z" } } as never,
|
||||
],
|
||||
id: `a_quiz_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-quiz", ...(input !== undefined ? { input } : {}) } as never],
|
||||
};
|
||||
}
|
||||
|
||||
/** The accept button's message. */
|
||||
function planAccepted(): LhViewMessage {
|
||||
return verdict(true);
|
||||
}
|
||||
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||
|
||||
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");
|
||||
test("phaseAt3: probe until a concluded probe_done; plan until the first nodeId-tagged quiz; teach after", () => {
|
||||
const probe = phaseAt3([user("teach me calculus"), assistantQuiz({ question: "q", correctAnswer: "a" })]);
|
||||
assert.equal(probe, "probe", "an UNTAGGED quiz alone never ends the probe (probe quizzes carry no nodeId)");
|
||||
|
||||
const concluded = phaseAt3([
|
||||
user("teach me"),
|
||||
@@ -66,49 +59,43 @@ 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("looks good, go"),
|
||||
]);
|
||||
assert.equal(presented, "plan", "a successful plan_init PRESENTS the map — planning continues until the verdict");
|
||||
assert.equal(presented, "plan", "plan_init + the learner's go-ahead TEXT present and sanction nothing");
|
||||
|
||||
const accepted = phaseAt3([
|
||||
const teaching = phaseAt3([
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
planAccepted(),
|
||||
user("looks good, go"),
|
||||
assistantQuiz({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" }),
|
||||
]);
|
||||
assert.equal(accepted, "teach", "the learner's acceptance ends planning");
|
||||
|
||||
const declined = phaseAt3([
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
verdict(false),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
]);
|
||||
assert.equal(declined, "plan", "a DECLINED verdict is a real event but stays in planning until a later acceptance");
|
||||
assert.equal(teaching, "teach", "the first nodeId-tagged quiz CALL is where teaching begins");
|
||||
|
||||
const failedPlan = phaseAt3([
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
assistantTool("plan_init", "output-error", { ok: false, error: "bad mermaid" }),
|
||||
planAccepted(),
|
||||
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }),
|
||||
]);
|
||||
assert.equal(failedPlan, "teach", "acceptance sanctions teaching even after a failed init attempt — the verdict is his");
|
||||
assert.equal(failedPlan, "teach", "a tagged quiz begins teaching even after a failed init attempt");
|
||||
});
|
||||
|
||||
test("phaseAt3: the verdict marker is explicit — anonymous learner text never sanctions teaching", () => {
|
||||
test("phaseAt3: the tag is the seam — natural text and untagged questions never start 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
|
||||
user("The plan is accepted — begin teaching."), // words alone
|
||||
assistantQuiz({ question: "ad-hoc check", correctAnswer: "a" }), // untagged
|
||||
]);
|
||||
assert.equal(plainText, "plan", "only the data-verdict part is the seam — text can never fake it");
|
||||
assert.equal(plainText, "plan", "only a nodeId-tagged quiz flips the seam — text and ad-hoc checks never do");
|
||||
|
||||
const acceptOutOfOrder = phaseAt3([
|
||||
const taggedOutOfOrder = phaseAt3([
|
||||
user("teach me"),
|
||||
verdict(true), // before any probe_done: out of order, advances nothing
|
||||
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }), // before probe_done
|
||||
]);
|
||||
assert.equal(acceptOutOfOrder, "probe", "the chain never skips");
|
||||
assert.equal(taggedOutOfOrder, "probe", "the chain never skips");
|
||||
});
|
||||
|
||||
test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keeps the probe open", () => {
|
||||
@@ -128,13 +115,69 @@ test("phaseTimeline: folds messages into phase spans for the dividers", () => {
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||
user("what does node 1 mean?"), // side question — still planning
|
||||
planAccepted(),
|
||||
user("go on"),
|
||||
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }),
|
||||
assistantQuiz({ question: "q2", nodeId: "n2", correctAnswer: "a" }),
|
||||
]);
|
||||
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "teach", "teach"]);
|
||||
assert.equal(timeline.length, 7);
|
||||
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "plan", "teach", "teach"]);
|
||||
assert.equal(timeline.length, 8);
|
||||
});
|
||||
|
||||
test("phaseAt3: empty transcript is probe", () => {
|
||||
assert.equal(phaseAt3([]), "probe");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the native fold (phaseAt3Lh over LhMessage[]) ──────────────────────────
|
||||
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { phaseAt3Lh } from "../src/core/phases.ts";
|
||||
|
||||
/** A native toolResult (the probe_done conclusion). */
|
||||
function toolResult(toolName: string, isError = false): LhMessage {
|
||||
return {
|
||||
id: `tr_${toolName}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "toolResult",
|
||||
toolCallId: `call_${toolName}`,
|
||||
toolName,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError,
|
||||
timestamp: Date.now(),
|
||||
} as LhMessage;
|
||||
}
|
||||
|
||||
/** A native assistant message carrying a quiz toolCall — arguments carry
|
||||
* `nodeId` iff tagged (exactly what the wire would persist). */
|
||||
function assistantQuizCall(arguments_: Record<string, unknown>): LhMessage {
|
||||
return {
|
||||
id: `a_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_quiz", name: "quiz", arguments: arguments_ }],
|
||||
stopReason: "toolUse",
|
||||
usage: undefined,
|
||||
timestamp: Date.now(),
|
||||
} as unknown as LhMessage;
|
||||
}
|
||||
|
||||
test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => {
|
||||
const done = toolResult("probe_done");
|
||||
const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" });
|
||||
const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" });
|
||||
|
||||
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged quiz call never starts teaching");
|
||||
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged quiz CALL is the seam");
|
||||
assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)");
|
||||
assert.equal(
|
||||
phaseAt3Lh([toolResult("probe_done", true), tagged]),
|
||||
"probe",
|
||||
"an ERRORED probe_done concludes nothing",
|
||||
);
|
||||
// The two folds agree on the same settled transcript (the view projection
|
||||
// of the tagged call carries its arguments as the part input).
|
||||
assert.equal(
|
||||
phaseAt3Lh([done, tagged]),
|
||||
phaseAt3([
|
||||
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
|
||||
{ parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,24 +57,17 @@ describe("Composer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The Enter decision (user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
||||
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent
|
||||
// a plain message, leaving the verdict pending again on the next message.
|
||||
// Pure function so the static-render harness can pin it (key events can't be
|
||||
// simulated in renderToStaticMarkup).
|
||||
// The Enter decision: stop a busy stream, otherwise send. (The plan-verdict
|
||||
// accept branch died 2026-09-26 with the verdict itself — the post-plan
|
||||
// reply is a natural text message.) Pure function so the static-render
|
||||
// harness can pin it (key events can't be simulated in renderToStaticMarkup).
|
||||
describe("enterAction", () => {
|
||||
it("busy stream: Enter stops — verdict or not", () => {
|
||||
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop");
|
||||
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
|
||||
it("busy stream: Enter stops", () => {
|
||||
assert.equal(enterAction({ busy: true, canSend: true }), "stop");
|
||||
});
|
||||
|
||||
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => {
|
||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note");
|
||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict");
|
||||
});
|
||||
|
||||
it("no verdict: send when there is text, nothing when there is not", () => {
|
||||
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: true }), "send");
|
||||
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: false }), "none");
|
||||
it("idle: send when there is text, nothing when there is not", () => {
|
||||
assert.equal(enterAction({ busy: false, canSend: true }), "send");
|
||||
assert.equal(enterAction({ busy: false, canSend: false }), "none");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import { afterEach, describe, it } from "node:test";
|
||||
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
||||
import type { ChatInput } from "../src/core/input-events.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { newUserMessage, newAnswerResult, newVerdictMessage, needsContinuation } from "../src/core/native-messages.ts";
|
||||
import { newUserMessage, newAnswerResult, needsContinuation } from "../src/core/native-messages.ts";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
@@ -589,10 +589,6 @@ describe("adoptRecord — append-only adoption", () => {
|
||||
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
|
||||
"the answer result absorbs by toolCallId",
|
||||
);
|
||||
// A verdict marker absorbs by accepted (the timestamps differ by design).
|
||||
const verdict = { ...newVerdictMessage(true, "2026-09-26T00:00:00.000Z"), id: "c_local_3" } as LhMessage;
|
||||
const serverVerdict = { ...newVerdictMessage(true, "2026-09-26T00:00:01.000Z"), id: "srv_11" } as LhMessage;
|
||||
assert.ok(absorbed(verdict, [serverVerdict]), "the verdict absorbs by accepted");
|
||||
});
|
||||
|
||||
it("setMessages is the full snapshot swap (the idle settle sync)", () => {
|
||||
|
||||
Reference in New Issue
Block a user