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:
+13
-54
@@ -128,7 +128,7 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
|
|||||||
* as a sub-agent result — rendered as an artifact card, never a bubble. */
|
* as a sub-agent result — rendered as an artifact card, never a bubble. */
|
||||||
/** The quiet system line (custom-message scheme rendering): centered, muted,
|
/** The quiet system line (custom-message scheme rendering): centered, muted,
|
||||||
* small — the transcript's voice for events that are not speech (wake
|
* small — the transcript's voice for events that are not speech (wake
|
||||||
* scaffolding, the learner's plan verdict). */
|
* scaffolding). */
|
||||||
function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) {
|
function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -402,7 +402,7 @@ export function Chat({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
/** The last explicitly-sent draft (composer send, verdict note): the
|
/** The last explicitly-sent draft (composer send): the
|
||||||
* refusal flow restores it into the composer and Save replays it.
|
* refusal flow restores it into the composer and Save replays it.
|
||||||
* Auto-continuations (probe answers) never set it — their refusal
|
* Auto-continuations (probe answers) never set it — their refusal
|
||||||
* opens the dialog with no replay. */
|
* opens the dialog with no replay. */
|
||||||
@@ -575,35 +575,6 @@ export function Chat({
|
|||||||
// derives its own from the same transcript).
|
// derives its own from the same transcript).
|
||||||
const planTree = useMemo(() => latestTree(messages), [messages]);
|
const planTree = useMemo(() => latestTree(messages), [messages]);
|
||||||
|
|
||||||
// ── The plan verdict gate (docs/probe-teach-split.md §4) ──
|
|
||||||
// The plan has been presented (a tree exists) and the session still sits in
|
|
||||||
// the planning phase → the composer's send square becomes the stacked
|
|
||||||
// Accept / Decline buttons. Acceptance is the explicit plan→teach seam
|
|
||||||
// (a data-plan-accepted part on the message); decline asks for revision.
|
|
||||||
const planVerdict = useMemo(
|
|
||||||
() => phaseAt3(messages) === "plan" && planTree !== null,
|
|
||||||
[messages, planTree],
|
|
||||||
);
|
|
||||||
const sendVerdict = useCallback(
|
|
||||||
(accepted: boolean) => {
|
|
||||||
const note = text.trim();
|
|
||||||
if (note) draftRef.current = note; // refusal flow: the note replays as text
|
|
||||||
setText("");
|
|
||||||
// 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 toLlmMessages,
|
|
||||||
// the native convertToLlm seam; nothing fake is persisted as speech).
|
|
||||||
// ONE input event, ONE turn: the session hook appends the optimistic
|
|
||||||
// note + verdict marker (local ids — the settle sync lands the
|
|
||||||
// server canon) and POSTs {type:"verdict"}; the server persists the
|
|
||||||
// pair as its own write-ahead — the same single-turn mechanism
|
|
||||||
// syncFromServer uses, no 409 dance, no double teacher turn.
|
|
||||||
void send({ type: "verdict", accepted, note: note || undefined });
|
|
||||||
},
|
|
||||||
[text, send],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Session usage (wave-4 stats bar) ──
|
// ── Session usage (wave-4 stats bar) ──
|
||||||
// The money channel: the record's turns log, folded client-side by
|
// The money channel: the record's turns log, folded client-side by
|
||||||
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
||||||
@@ -1076,21 +1047,17 @@ export function Chat({
|
|||||||
// assistant group — is gone). Never a user bubble: the wake is
|
// assistant group — is gone). Never a user bubble: the wake is
|
||||||
// scaffolding; the model-facing instruction lives in the adapter.
|
// scaffolding; the model-facing instruction lives in the adapter.
|
||||||
<PhaseDivider key={m.id || `m-${mi}`} phase="plan" />
|
<PhaseDivider key={m.id || `m-${mi}`} phase="plan" />
|
||||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-verdict") ? (
|
) : m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
|
||||||
// The learner's plan verdict (custom-message scheme): the
|
// THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
|
||||||
// accept IS the phase seam — it renders the TEACHING DIVIDER
|
// death): teaching begins where the FIRST nodeId-tagged quiz is
|
||||||
// (owner ruling 2026-09-26: replaced the "✓ Plan accepted"
|
// posed — the fold (phaseTimeline, the same derivation the phase
|
||||||
// line; same spot, one signal, not two). A DECLINE is not a
|
// chip reads) flips to teach exactly AT this assistant group, so
|
||||||
// seam — it stays the quiet revision line. Never a user
|
// the divider renders before the whole block (thinking -> prose ->
|
||||||
// bubble: the model-facing wording is the adapter's, and this
|
// the quiz call) — where the explicit accept marker used to sit.
|
||||||
// is the learner's ACT, not their words (a typed note, if
|
// The boundary check (this index flips, the previous didn't)
|
||||||
// any, renders above as its own real message).
|
// guarantees exactly ONE divider per session.
|
||||||
((m.parts.find((p) => p.type === "data-verdict") as
|
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
|
||||||
| { data?: { accepted?: boolean } }
|
) : (
|
||||||
| undefined)?.data?.accepted)
|
|
||||||
? <PhaseDivider key={m.id || `m-${mi}`} phase="teach" />
|
|
||||||
: <SystemLine key={m.id || `m-${mi}`} testId="verdict-declined">↻ Plan revision requested</SystemLine>
|
|
||||||
) : (
|
|
||||||
<Fragment key={m.id || `m-${mi}`}>
|
<Fragment key={m.id || `m-${mi}`}>
|
||||||
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||||
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
||||||
@@ -1234,14 +1201,6 @@ export function Chat({
|
|||||||
onStop={abortInFlightTurn}
|
onStop={abortInFlightTurn}
|
||||||
canSend={canSend}
|
canSend={canSend}
|
||||||
onEscape={dictation.active ? dictation.cancel : undefined}
|
onEscape={dictation.active ? dictation.cancel : undefined}
|
||||||
verdict={
|
|
||||||
planVerdict
|
|
||||||
? {
|
|
||||||
onAccept: () => sendVerdict(true),
|
|
||||||
onDecline: () => sendVerdict(false),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onSubmitAnswer={(output) => {
|
onSubmitAnswer={(output) => {
|
||||||
|
|||||||
+15
-60
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
|
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
|
||||||
@@ -12,16 +12,13 @@ import { Hint } from "@/components/ui/hint";
|
|||||||
* Corner-radius math (concentric with the shell): shell rounded-2xl (16px) −
|
* Corner-radius math (concentric with the shell): shell rounded-2xl (16px) −
|
||||||
* row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button.
|
* row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button.
|
||||||
*/
|
*/
|
||||||
/** What one Enter press does, in precedence order (the plan-verdict
|
/** What one Enter press does, in precedence order: stop a busy stream,
|
||||||
* behavior is user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
* otherwise send the message. (The plan-verdict accept branch died
|
||||||
* stop a busy stream; ACCEPT when the plan verdict pends — typed text
|
* 2026-09-26 with the verdict itself.) Pure so the static-render harness
|
||||||
* rides the acceptance as the note (the green button's exact semantics;
|
* can pin the decision (key events can't be simulated in
|
||||||
* an empty box accepts too, so Enter can never strand the verdict);
|
* renderToStaticMarkup). */
|
||||||
* otherwise send the message. Pure so the static-render harness can pin
|
export function enterAction(state: { busy: boolean; canSend: boolean }): "stop" | "send" | "none" {
|
||||||
* the decision (key events can't be simulated in renderToStaticMarkup). */
|
|
||||||
export function enterAction(state: { busy: boolean; verdictPending: boolean; canSend: boolean }): "stop" | "accept" | "send" | "none" {
|
|
||||||
if (state.busy) return "stop";
|
if (state.busy) return "stop";
|
||||||
if (state.verdictPending) return "accept";
|
|
||||||
if (state.canSend) return "send";
|
if (state.canSend) return "send";
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
@@ -34,7 +31,6 @@ export function Composer({
|
|||||||
onStop,
|
onStop,
|
||||||
canSend,
|
canSend,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
verdict,
|
|
||||||
onEscape,
|
onEscape,
|
||||||
placeholder = "Ask, answer, or steer the lesson…",
|
placeholder = "Ask, answer, or steer the lesson…",
|
||||||
}: {
|
}: {
|
||||||
@@ -45,10 +41,6 @@ export function Composer({
|
|||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** The plan verdict gate: while the presented plan awaits the learner's
|
|
||||||
* word, the send square becomes two stacked buttons — green accept,
|
|
||||||
* red decline. Typed text rides the verdict as his note. */
|
|
||||||
verdict?: { onAccept: () => void; onDecline: () => void };
|
|
||||||
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
||||||
* when absent — Esc otherwise means nothing in the composer. */
|
* when absent — Esc otherwise means nothing in the composer. */
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
@@ -63,9 +55,8 @@ export function Composer({
|
|||||||
className="flex items-end gap-1.5 p-1.5"
|
className="flex items-end gap-1.5 p-1.5"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
|
const action = enterAction({ busy, canSend });
|
||||||
if (action === "stop") onStop();
|
if (action === "stop") onStop();
|
||||||
else if (action === "accept") verdict?.onAccept();
|
|
||||||
else if (action === "send") onSend();
|
else if (action === "send") onSend();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -86,56 +77,21 @@ export function Composer({
|
|||||||
// Chat convention: Enter sends, Shift+Enter is a newline.
|
// Chat convention: Enter sends, Shift+Enter is a newline.
|
||||||
// ⌘/Ctrl+Enter also sends — the universal submit shortcut
|
// ⌘/Ctrl+Enter also sends — the universal submit shortcut
|
||||||
// across every input in the app (thinking field, answers).
|
// across every input in the app (thinking field, answers).
|
||||||
// While the plan verdict pends, Enter ACCEPTS the plan instead
|
|
||||||
// (enterAction — user-decided 2026-09-19).
|
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
|
const action = enterAction({ busy, canSend });
|
||||||
if (action === "stop") onStop();
|
if (action === "stop") onStop();
|
||||||
else if (action === "accept") verdict?.onAccept();
|
else if (action === "send") onSend();
|
||||||
else if (action === "send") onSend();
|
}
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
{verdict && !busy ? (
|
{/* No tooltip on the send square — the ↵ glyph is the
|
||||||
// The plan verdict (docs/probe-teach-split.md §4): accept
|
affordance and Enter is obvious (owner, subtlety
|
||||||
// sanctions teaching (phase plan→teach); decline sends the
|
round 2026-09-25). */}
|
||||||
// revision request. Enter accepts too (user-decided 2026-09-19 —
|
|
||||||
// it formerly sent a plain message, which left the verdict
|
|
||||||
// pending on the next message; a side question now rides the
|
|
||||||
// acceptance as its note instead).
|
|
||||||
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
|
|
||||||
<Hint
|
|
||||||
// Enter accepts too — one press, either button (the user
|
|
||||||
// decided 2026-09-19; surface keys honestly per state).
|
|
||||||
label="Accept the plan"
|
|
||||||
binding="enter"
|
|
||||||
side="left"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={verdict.onAccept}
|
|
||||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
|
|
||||||
>
|
|
||||||
<CheckIcon className="size-3" /> Accept plan
|
|
||||||
</button>
|
|
||||||
</Hint>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={verdict.onDecline}
|
|
||||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-xs font-medium text-destructive-foreground transition-colors hover:opacity-90"
|
|
||||||
>
|
|
||||||
<XIcon className="size-3" /> Decline
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// No tooltip on the send square — the ↵ glyph is the
|
|
||||||
// affordance and Enter is obvious (owner, subtlety
|
|
||||||
// round 2026-09-25).
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label={busy ? "Stop" : "Send"}
|
aria-label={busy ? "Stop" : "Send"}
|
||||||
@@ -149,7 +105,6 @@ export function Composer({
|
|||||||
>
|
>
|
||||||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
needsContinuation,
|
needsContinuation,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
newVerdictMessage,
|
|
||||||
projectView,
|
projectView,
|
||||||
type ChatInput,
|
type ChatInput,
|
||||||
type LhMessage,
|
type LhMessage,
|
||||||
@@ -133,16 +132,6 @@ export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
|
|||||||
(r as ToolResultMessage).toolCallId === toolCallId,
|
(r as ToolResultMessage).toolCallId === toolCallId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (role === "custom") {
|
|
||||||
const marker = m as { customType?: string; details?: { accepted?: unknown } };
|
|
||||||
if (marker.customType !== "verdict") return false;
|
|
||||||
return record.some(
|
|
||||||
(r) =>
|
|
||||||
(r as { role?: string }).role === "custom" &&
|
|
||||||
(r as { customType?: string }).customType === "verdict" &&
|
|
||||||
(r as { details?: { accepted?: unknown } }).details?.accepted === marker.details?.accepted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,12 +454,6 @@ export class SessionModel {
|
|||||||
else if (input.type === "answer") {
|
else if (input.type === "answer") {
|
||||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
||||||
} else if (input.type === "verdict") {
|
|
||||||
if (input.note) optimistic.push({ ...newUserMessage(input.note), id: localId() });
|
|
||||||
optimistic.push({
|
|
||||||
...newVerdictMessage(input.accepted, new Date().toISOString()),
|
|
||||||
id: localId(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (optimistic.length > 0) this.mutate(() => {
|
if (optimistic.length > 0) this.mutate(() => {
|
||||||
this.tail = [...this.tail, ...optimistic];
|
this.tail = [...this.tail, ...optimistic];
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ flowchart TB
|
|||||||
|
|
||||||
- `contracts/plan.ts` — plan tree types + derived statuses (`derivePlanStatuses`)
|
- `contracts/plan.ts` — plan tree types + derived statuses (`derivePlanStatuses`)
|
||||||
- `contracts/graph.ts` — graph domain: ledgers, findings/audit shapes, snapshot, birth-session clusters
|
- `contracts/graph.ts` — graph domain: ledgers, findings/audit shapes, snapshot, birth-session clusters
|
||||||
- `contracts/chat.ts` — session records, usage/money channel, settings, verdicts
|
- `contracts/chat.ts` — session records, usage/money channel, settings, handoff text
|
||||||
- `contracts/jobs.ts` — the ack-and-steer protocol: job records, steer message builders, deterministic steer ids, usage accumulation
|
- `contracts/jobs.ts` — the ack-and-steer protocol: job records, steer message builders, deterministic steer ids, usage accumulation
|
||||||
- `contracts/probe.ts` — probe assessment + the shared zod schema
|
- `contracts/probe.ts` — probe assessment + the shared zod schema
|
||||||
- `contracts/models.ts` — client-safe model-catalog shapes
|
- `contracts/models.ts` — client-safe model-catalog shapes
|
||||||
|
|||||||
+16
-17
@@ -95,24 +95,22 @@ That is the wrong signal and is **abolished**. The new contract:
|
|||||||
|
|
||||||
The phase chain is **event-driven**:
|
The phase chain is **event-driven**:
|
||||||
|
|
||||||
**probe (prober)** — `probe_done` → **planning (teacher)** — `plan_init`
|
**probe (prober)** — `probe_done` → **planning (teacher)** — the first
|
||||||
presents the plan → **accept** → **teach (teacher)**
|
`nodeId`-tagged quiz call → **teach (teacher)**
|
||||||
|
|
||||||
- The broken derived-occupancy seam (phase-of-tree via `currentNodeId`
|
- The broken derived-occupancy seam (phase-of-tree via `currentNodeId`
|
||||||
nothing ever sets — the stuck-on-plan bug) is **replaced, not patched**.
|
nothing ever sets — the stuck-on-plan bug) is **replaced, not patched**.
|
||||||
- The verdict gate is LIVE (landed 2026-09-11, same day): presenting the
|
- The verdict gate is DEAD (owner ruling 2026-09-26 — simplified away):
|
||||||
plan (`plan_init`) does NOT move the phase — the learner's **accept**
|
presenting the plan ends the teacher's turn, and the learner's next
|
||||||
does. The composer's send square becomes two stacked buttons (green
|
message is a natural reply — a change request (revise, re-present, end
|
||||||
Accept plan / red Decline) while the verdict pends; accept sends a user
|
the turn again) or the go-ahead. The plan→teach seam is **inferred**:
|
||||||
message carrying a `data-verdict` data part — THE plan→teach seam
|
the first quiz call tagged with a `nodeId` (a question ABOUT a plan
|
||||||
(`phaseAt3` reads it; `convertToModelMessages` drops data parts, so the
|
node; probe quizzes carry no nodeId) is where teaching begins. The
|
||||||
teacher sees only the text). Decline asks for a revision. While the
|
phase fold (`phaseAt3`) advances there, the teaching divider renders
|
||||||
verdict pends, Enter ACCEPTS the plan — typed text rides the acceptance
|
before that whole assistant block, and the live phase indicator may lag
|
||||||
as its note, an empty box accepts too (user-decided 2026-09-19; formerly
|
until the first node's question is posed (owner-accepted). Mid-lesson
|
||||||
Enter sent a plain message, which left the verdict still pending on the
|
`plan_init` rewrites need no gate at all.
|
||||||
next message — a side question now rides the acceptance note instead).
|
- The session picker phase derives from the transcript the same way.
|
||||||
Mid-lesson rewrites need no fresh verdict: acceptance happens once.
|
|
||||||
- The session picker phase derives from transcript markers the same way.
|
|
||||||
|
|
||||||
## 5. No global-graph context in sessions (for now)
|
## 5. No global-graph context in sessions (for now)
|
||||||
|
|
||||||
@@ -140,8 +138,9 @@ live-derived movement.
|
|||||||
|
|
||||||
## 7. Deferred / later waves (designed, not now)
|
## 7. Deferred / later waves (designed, not now)
|
||||||
|
|
||||||
- ~~**Accept-plan UI**~~ — **landed 2026-09-11** (see §4): stacked
|
- ~~**Accept-plan UI**~~ — landed 2026-09-11, then **removed 2026-09-26**
|
||||||
Accept/Decline buttons; the `data-verdict` part is the seam.
|
(the verdict death): the seam is inferred from the first `nodeId`-tagged
|
||||||
|
quiz call (see §4).
|
||||||
- **Assessment viewer** — the strand assessments displayed to the learner
|
- **Assessment viewer** — the strand assessments displayed to the learner
|
||||||
as a proper card (data already durable in the transcript; the plain v1
|
as a proper card (data already durable in the transcript; the plain v1
|
||||||
probe-done card renders the assessment meanwhile).
|
probe-done card renders the assessment meanwhile).
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
import "./.next/dev/types/root-params.d.ts";
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ Two parts:
|
|||||||
|
|
||||||
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|
||||||
|
|
||||||
**Presenting ENDS your turn.** The moment the plan is presented, his interface shows two verdict buttons — Accept plan / Decline — and the session waits for his word before any teaching is sanctioned. Do not ask a check or teach before it arrives.
|
**Presenting ENDS your turn.** The plan is presented in prose and map, and then you stop — his next message is the natural next move. Do not ask a check or teach in the same turn as presenting.
|
||||||
|
|
||||||
- **Acceptance** arrives as a message like "[Plan accepted] — begin teaching." Begin the first node.
|
- If his reply asks for changes, revise: fix the map (`plan_init` again — a wholesale rewrite), present again, and end your turn once more.
|
||||||
- **Decline** arrives as a revision request. Ask what he'd like changed if it isn't clear, fix the map (`plan_init` again — a wholesale rewrite), present again, and wait once more.
|
- Otherwise his reply moves the lesson forward — begin the first node.
|
||||||
|
|
||||||
The verdict is his alone to give — never ask a question whose answer would assume it, and never start teaching into an unaccepted plan. A plan he interacts with mid-lesson (a `plan_init` rewrite during teaching) needs no fresh verdict: acceptance happens once, at the first presentation.
|
Never start teaching in the same turn as presenting the plan. A plan rewrite mid-lesson needs no fresh presentation gate: just re-call `plan_init` and carry on.
|
||||||
@@ -181,7 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
steeringMode: "all",
|
steeringMode: "all",
|
||||||
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
|
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
|
||||||
// THE app seam — markers flatten to their canonical user text
|
// THE app seam — markers flatten to their canonical user text
|
||||||
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced),
|
// (HANDOFF_TEXT / steer text, all single-sourced),
|
||||||
// UI-only markers (plan-block) drop, the app's id fields strip, and
|
// UI-only markers (plan-block) drop, the app's id fields strip, and
|
||||||
// an abort's dangling question call gets its INTERRUPTED result. It
|
// an abort's dangling question call gets its INTERRUPTED result. It
|
||||||
// must never throw (pi's convertToLlm contract).
|
// must never throw (pi's convertToLlm contract).
|
||||||
|
|||||||
@@ -149,30 +149,6 @@ export interface TurnUsage {
|
|||||||
* block renders current node + direct parents + direct children in its own
|
* block renders current node + direct parents + direct children in its own
|
||||||
* small container, the current node glowing — clearly a fragment, not the
|
* small container, the current node glowing — clearly a fragment, not the
|
||||||
* whole plan. Clicking either opens the full plan in a popup. */
|
* whole plan. Clicking either opens the full plan in a popup. */
|
||||||
/** 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 toLlmMessages (native-messages.ts
|
|
||||||
* flattens it to verdictText 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
|
/** 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
|
* scheme): the wake message carries only the data-probe-handoff part, and
|
||||||
@@ -185,14 +161,16 @@ export function verdictText(accepted: boolean): string {
|
|||||||
* fragment asks for. So the wake paces it: reason out the optimal way to
|
* fragment asks for. So the wake paces it: reason out the optimal way to
|
||||||
* teach THIS person THIS thing BEFORE plan_init. Honest to the machinery
|
* teach THIS person THIS thing BEFORE plan_init. Honest to the machinery
|
||||||
* — plan_init is still the first tool, the plan map still the first
|
* — plan_init is still the first tool, the plan map still the first
|
||||||
* artifact, and presenting still waits for the verdict. */
|
* artifact, and presenting still ends the turn (the verdict death,
|
||||||
|
* 2026-09-26: the learner's next message is a natural reply — a change
|
||||||
|
* request or the go-ahead). */
|
||||||
export const HANDOFF_TEXT =
|
export const HANDOFF_TEXT =
|
||||||
"The probe is concluded — the learner assessment is recorded. " +
|
"The probe is concluded — the learner assessment is recorded. " +
|
||||||
"This is the session's highest-leverage moment, and nothing has been authored yet — take your time. " +
|
"This is the session's highest-leverage moment, and nothing has been authored yet — take your time. " +
|
||||||
"Read his assessment and the probing transcript, and reason out the optimal way to teach this person this thing: " +
|
"Read his assessment and the probing transcript, and reason out the optimal way to teach this person this thing: " +
|
||||||
"what he already holds and where each strand runs out, the misconceptions to route around, " +
|
"what he already holds and where each strand runs out, the misconceptions to route around, " +
|
||||||
"and the minimal tree from what he holds as obviously true to his goal — so every step lands as obviously true. " +
|
"and the minimal tree from what he holds as obviously true to his goal — so every step lands as obviously true. " +
|
||||||
"When the plan is genuinely reasoned out, call plan_init and present it, then wait for his verdict.";
|
"When the plan is genuinely reasoned out, call plan_init, present it, and end your turn — his next message either asks for changes or moves the lesson forward.";
|
||||||
|
|
||||||
|
|
||||||
export interface PlanBlockData {
|
export interface PlanBlockData {
|
||||||
|
|||||||
@@ -13,13 +13,13 @@
|
|||||||
* built client-side exactly as today; a dismissal is an answer
|
* built client-side exactly as today; a dismissal is an answer
|
||||||
* carrying its flag). Server-side this becomes the call's
|
* carrying its flag). Server-side this becomes the call's
|
||||||
* native tool result — fork 1's normal agent history.
|
* native tool result — fork 1's normal agent history.
|
||||||
* verdict — the plan verdict (accept/decline); an optional note is the
|
|
||||||
* learner's own real user message, sent first, exactly the
|
|
||||||
* two-real-messages scheme.
|
|
||||||
* continue — run the next turn on the current record (mount
|
* continue — run the next turn on the current record (mount
|
||||||
* continuations, job-settle re-syncs: the record ends at an
|
* continuations, job-settle re-syncs: the record ends at an
|
||||||
* answered question with no teacher reaction yet).
|
* answered question with no teacher reaction yet).
|
||||||
*
|
*
|
||||||
|
* (The plan verdict input died 2026-09-26 with the verdict itself — the
|
||||||
|
* learner's post-plan reply is a natural text message.)
|
||||||
|
*
|
||||||
* Server-originated delivery (the wake pipeline's steers) is NOT a client
|
* Server-originated delivery (the wake pipeline's steers) is NOT a client
|
||||||
* input — the handler accepts it on the same path (TurnInput) so every
|
* input — the handler accepts it on the same path (TurnInput) so every
|
||||||
* turn rides one composition.
|
* turn rides one composition.
|
||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
danglingQuestionOf,
|
danglingQuestionOf,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
newVerdictMessage,
|
|
||||||
} from "./native-messages.ts";
|
} from "./native-messages.ts";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ import { QUESTION_TOOLS } from "./pending.ts";
|
|||||||
export type ChatInput =
|
export type ChatInput =
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
||||||
| { type: "verdict"; accepted: boolean; note?: string }
|
|
||||||
| { type: "continue" };
|
| { type: "continue" };
|
||||||
|
|
||||||
/** A wake-pipeline steer delivery — same turn composition, server-origin.
|
/** A wake-pipeline steer delivery — same turn composition, server-origin.
|
||||||
@@ -73,12 +71,6 @@ export function parseChatInput(input: unknown): ChatInput {
|
|||||||
throw new ChatInputError("answer input requires the graded answer object");
|
throw new ChatInputError("answer input requires the graded answer object");
|
||||||
}
|
}
|
||||||
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
|
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
|
||||||
case "verdict":
|
|
||||||
if (typeof i.accepted !== "boolean") throw new ChatInputError("verdict input requires accepted");
|
|
||||||
if (i.note !== undefined && typeof i.note !== "string") {
|
|
||||||
throw new ChatInputError("verdict note must be a string");
|
|
||||||
}
|
|
||||||
return { type: "verdict", accepted: i.accepted, note: i.note as string | undefined };
|
|
||||||
case "continue":
|
case "continue":
|
||||||
return { type: "continue" };
|
return { type: "continue" };
|
||||||
default:
|
default:
|
||||||
@@ -106,14 +98,6 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
|
|||||||
}
|
}
|
||||||
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
|
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
|
||||||
}
|
}
|
||||||
case "verdict": {
|
|
||||||
const appends: LhMessage[] = [];
|
|
||||||
if (input.note && input.note.trim().length > 0) {
|
|
||||||
appends.push(newUserMessage(input.note));
|
|
||||||
}
|
|
||||||
appends.push(newVerdictMessage(input.accepted, new Date().toISOString()));
|
|
||||||
return appends;
|
|
||||||
}
|
|
||||||
case "continue":
|
case "continue":
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* sees it (verified single site, below).
|
* sees it (verified single site, below).
|
||||||
*
|
*
|
||||||
* UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message
|
* UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message
|
||||||
* taxonomy): verdict markers, the probe→teach handoff, sub-agent steers,
|
* taxonomy): the probe→teach handoff, sub-agent steers,
|
||||||
* and plan blocks are LhMarkerMessage — this app's declaration-merged
|
* and plan blocks are LhMarkerMessage — this app's declaration-merged
|
||||||
* member of pi-agent-core's CustomAgentMessages extension point, shaped
|
* member of pi-agent-core's CustomAgentMessages extension point, shaped
|
||||||
* exactly like pi's own CustomMessage ({role:"custom", customType,
|
* exactly like pi's own CustomMessage ({role:"custom", customType,
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
import { HANDOFF_TEXT, verdictText } from "./contracts/chat.ts";
|
import { HANDOFF_TEXT } from "./contracts/chat.ts";
|
||||||
|
|
||||||
/** The hard-invariant wording for an aborted turn's unanswered calls —
|
/** The hard-invariant wording for an aborted turn's unanswered calls —
|
||||||
* carried over verbatim from the pre-native era so the model's view
|
* carried over verbatim from the pre-native era so the model's view
|
||||||
@@ -58,7 +58,7 @@ export interface LhMarkerMessage {
|
|||||||
/** Record identity (pi messages carry no id — see the file header). */
|
/** Record identity (pi messages carry no id — see the file header). */
|
||||||
id: string;
|
id: string;
|
||||||
role: "custom";
|
role: "custom";
|
||||||
customType: "verdict" | "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
|
customType: "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
|
||||||
/** The model-facing text ("" for plan blocks — UI-only). */
|
/** The model-facing text ("" for plan blocks — UI-only). */
|
||||||
content: string;
|
content: string;
|
||||||
display: boolean;
|
display: boolean;
|
||||||
@@ -125,21 +125,6 @@ export function newAnswerResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The plan verdict (the plan→teach seam): marker only — the model-facing
|
|
||||||
* wording is owned by verdictText (core/contracts/chat.ts), the UI
|
|
||||||
* renders the quiet accept/decline line off details. */
|
|
||||||
export function newVerdictMessage(accepted: boolean, at: string): LhMarkerMessage {
|
|
||||||
return {
|
|
||||||
id: newMessageId(),
|
|
||||||
role: "custom",
|
|
||||||
customType: "verdict",
|
|
||||||
content: verdictText(accepted),
|
|
||||||
display: true,
|
|
||||||
details: { accepted, at },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The probe→teach handoff wake: scaffolding, not speech — the model reads
|
/** The probe→teach handoff wake: scaffolding, not speech — the model reads
|
||||||
* HANDOFF_TEXT, the UI renders the quiet system line. */
|
* HANDOFF_TEXT, the UI renders the quiet system line. */
|
||||||
export function newHandoffMessage(chatId: string): LhMarkerMessage {
|
export function newHandoffMessage(chatId: string): LhMarkerMessage {
|
||||||
|
|||||||
+57
-37
@@ -1,29 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
|
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
|
||||||
* occupancy. The probe→teach agent split (2026-09-11) gave every phase
|
* occupancy. The probe→teach agent split (2026-09-11) gave the first
|
||||||
* boundary a transcript event:
|
* boundary a transcript event; the second is INFERRED (owner ruling
|
||||||
|
* 2026-09-26, the verdict death):
|
||||||
*
|
*
|
||||||
* probe — until the prober's `probe_done` call CONCLUDES (a clean
|
* probe — until the prober's `probe_done` call CONCLUDES (a clean
|
||||||
* toolResult for it; the assessment rides the call's arguments)
|
* toolResult for it; the assessment rides the call's arguments)
|
||||||
* plan — probe concluded, until the learner ACCEPTS the presented
|
* plan — probe concluded, until teaching actually BEGINS
|
||||||
* plan (the verdict marker with accepted:true — the explicit
|
* teach — the first quiz tool call tagged with a `nodeId` (a question
|
||||||
* verdict gate; plan_init itself is only the PRESENTATION and
|
* ABOUT a plan node — probe quizzes carry no nodeId in their
|
||||||
* moves no phase; a declined verdict stays in plan)
|
* schema, so nothing false-triggers). The CALL is the seam, not
|
||||||
* teach — teaching is sanctioned
|
* its answer: the block that poses the first node question is
|
||||||
|
* where teaching starts.
|
||||||
|
*
|
||||||
|
* There is no verdict gate anymore: presenting the plan ends the teacher's
|
||||||
|
* turn, and the learner's next message is a natural reply (a change
|
||||||
|
* request, or the go-ahead). The live indicator LAGS by design (owner
|
||||||
|
* accepted): "teach" shows only once the first node's question is posed.
|
||||||
|
* A teacher that never tags a quiz would sit in "plan" visually —
|
||||||
|
* off-doctrine (tagging is prompt-mandated and nodeId is call-validated).
|
||||||
*
|
*
|
||||||
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
|
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
|
||||||
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a
|
* is GONE, as is the verdict-marker fold: no backwards compatibility
|
||||||
* session could never leave "plan"), and the stored tree no longer carries
|
* (legacy sessions are wiped data).
|
||||||
* progress fields at all. No backwards compatibility: legacy sessions
|
|
||||||
* without probe_done simply read as probe (they are wiped data).
|
|
||||||
*
|
*
|
||||||
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
|
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
|
||||||
* VIEW messages (the client's dividers and dock — part shapes, pure and
|
* VIEW messages (the client's dividers and dock — part shapes, pure and
|
||||||
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
|
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
|
||||||
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach
|
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach
|
||||||
* at the verdict marker. Both are strictly sequential; the two derivations
|
* at the assistant message carrying the first nodeId-tagged quiz CALL.
|
||||||
* agree on every settled record (a result/marker message projects into the
|
* Both are strictly sequential; the two derivations agree on every settled
|
||||||
* same view boundary).
|
* record (a call's part projects into the same view boundary).
|
||||||
*
|
*
|
||||||
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
||||||
*/
|
*/
|
||||||
@@ -33,10 +40,22 @@ export type Phase3 = "probe" | "plan" | "teach";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal structural slice of a UI message for the phase scan: parts with
|
* Minimal structural slice of a UI message for the phase scan: parts with
|
||||||
* a state and (for concluded calls) an output.
|
* a state, an input (a tool call's parsed arguments), and (for concluded
|
||||||
|
* calls) an output.
|
||||||
*/
|
*/
|
||||||
export interface PhaseMessage {
|
export interface PhaseMessage {
|
||||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
|
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A quiz call's part input carries `nodeId` iff it is tagged (teach-only —
|
||||||
|
* the probe quiz schema has no nodeId field). */
|
||||||
|
function taggedNodeId(input: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof input === "object" &&
|
||||||
|
input !== null &&
|
||||||
|
typeof (input as { nodeId?: unknown }).nodeId === "string" &&
|
||||||
|
(input as { nodeId: string }).nodeId.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The phase at `upToIndex` (inclusive; default: the last message). */
|
/** The phase at `upToIndex` (inclusive; default: the last message). */
|
||||||
@@ -52,23 +71,18 @@ export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number):
|
|||||||
/** One message's advance of the phase (pure fold step). */
|
/** One message's advance of the phase (pure fold step). */
|
||||||
function advance(
|
function advance(
|
||||||
phase: Phase3,
|
phase: Phase3,
|
||||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }> | undefined,
|
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }> | undefined,
|
||||||
): Phase3 {
|
): Phase3 {
|
||||||
if (!parts) return phase;
|
if (!parts) return phase;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
|
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
|
||||||
// plan → teach only via the learner's plan acceptance (data-verdict
|
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
|
||||||
// with accepted:true). A marker out of order advances nothing — the
|
// not its answer — an errored-and-retried call still proves teaching
|
||||||
// chain never skips. plan_init PRESENTS the map; only the verdict
|
// began, and the retry lands in the same group anyway).
|
||||||
// sanctions teaching (a decline stays in plan).
|
|
||||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||||
phase = "plan";
|
phase = "plan";
|
||||||
}
|
}
|
||||||
if (
|
if (phase === "plan" && part.type === "tool-quiz" && taggedNodeId(part.input)) {
|
||||||
phase === "plan" &&
|
|
||||||
part.type === "data-verdict" &&
|
|
||||||
(part as { data?: { accepted?: unknown } }).data?.accepted === true
|
|
||||||
) {
|
|
||||||
phase = "teach";
|
phase = "teach";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,11 +108,12 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
|
|||||||
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
|
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
|
||||||
|
|
||||||
import type { LhMessage } from "./native-messages.ts";
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
||||||
|
|
||||||
/** The phase at `upToIndex` (inclusive; default: the last message) of a
|
/** The phase at `upToIndex` (inclusive; default: the last message) of a
|
||||||
* NATIVE record. probe→plan advances at the probe_done call's clean
|
* NATIVE record. probe→plan advances at the probe_done call's clean
|
||||||
* toolResult; plan→teach at the verdict marker (details.accepted true). */
|
* toolResult; plan→teach at the assistant message carrying the first
|
||||||
|
* nodeId-tagged quiz CALL (mirrors the view fold — same seam, native
|
||||||
|
* shape). */
|
||||||
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
|
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
|
||||||
let phase: Phase3 = "probe";
|
let phase: Phase3 = "probe";
|
||||||
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||||
@@ -106,7 +121,7 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
|
|||||||
const m = messages[i]!;
|
const m = messages[i]!;
|
||||||
const role = (m as { role?: string }).role;
|
const role = (m as { role?: string }).role;
|
||||||
if (role === "toolResult") {
|
if (role === "toolResult") {
|
||||||
const result = m as ToolResultMessage;
|
const result = m as { toolName?: string; isError?: boolean };
|
||||||
if (
|
if (
|
||||||
phase === "probe" &&
|
phase === "probe" &&
|
||||||
result.toolName === "probe_done" &&
|
result.toolName === "probe_done" &&
|
||||||
@@ -114,14 +129,19 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
|
|||||||
) {
|
) {
|
||||||
phase = "plan";
|
phase = "plan";
|
||||||
}
|
}
|
||||||
} else if (role === "custom") {
|
} else if (role === "assistant") {
|
||||||
const marker = m as { customType?: string; details?: { accepted?: unknown } };
|
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
|
||||||
if (
|
if (phase === "plan") {
|
||||||
phase === "plan" &&
|
for (const block of content) {
|
||||||
marker.customType === "verdict" &&
|
if (
|
||||||
marker.details?.accepted === true
|
block.type === "toolCall" &&
|
||||||
) {
|
block.name === "quiz" &&
|
||||||
phase = "teach";
|
taggedNodeId(block.arguments)
|
||||||
|
) {
|
||||||
|
phase = "teach";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -36,7 +36,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"
|
|||||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||||
import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts";
|
import type { PlanBlockData } from "./contracts/chat.ts";
|
||||||
|
|
||||||
/** A view part — the loose part vocabulary the renderers consume (the same
|
/** A view part — the loose part vocabulary the renderers consume (the same
|
||||||
* shape names the pre-Wave-D UIMessage dialect used, so the renderer
|
* shape names the pre-Wave-D UIMessage dialect used, so the renderer
|
||||||
@@ -248,18 +248,9 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
|
|||||||
// rendered nothing) is dropped from the view — the record keeps it.
|
// rendered nothing) is dropped from the view — the record keeps it.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A marker's projected message (verdict/handoff/steers) or splice (plan block). */
|
/** A marker's projected message (handoff/steers) or splice (plan block). */
|
||||||
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
||||||
switch (marker.customType) {
|
switch (marker.customType) {
|
||||||
case "verdict": {
|
|
||||||
const data = marker.details as PlanVerdictData;
|
|
||||||
out.push({
|
|
||||||
id: marker.id,
|
|
||||||
role: "user",
|
|
||||||
parts: [{ type: "data-verdict", id: `vd_${marker.id}`, data }],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "probe-handoff": {
|
case "probe-handoff": {
|
||||||
out.push({
|
out.push({
|
||||||
id: marker.id,
|
id: marker.id,
|
||||||
|
|||||||
@@ -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"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+89
-46
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* phases — the event-driven phase chain (docs/probe-teach-split.md §4).
|
* phases — the event-driven phase chain (docs/probe-teach-split.md §4).
|
||||||
*
|
*
|
||||||
* probe ends at the first CONCLUDED probe_done (output-available); planning
|
* probe ends at the first CONCLUDED probe_done (output-available); teaching
|
||||||
* ends at the LEARNER'S plan acceptance (a data-verdict part with accepted:true
|
* begins at the FIRST nodeId-tagged quiz CALL (the inferred seam, owner
|
||||||
* accept button's user message — plan_init only PRESENTS the map). No
|
* 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
|
* 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
|
* never stick (the old currentNodeId-based phaseOfTree read a field nothing
|
||||||
* ever set).
|
* ever set).
|
||||||
@@ -31,30 +33,21 @@ function assistantTool(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
/** A quiz call with its parsed input — `nodeId` present iff tagged. */
|
||||||
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
function assistantQuiz(input: Record<string, unknown> | undefined): LhViewMessage {
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
return {
|
return {
|
||||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
id: `a_quiz_${Math.random().toString(36).slice(2, 6)}`,
|
||||||
role: "user",
|
role: "assistant",
|
||||||
parts: [
|
parts: [{ type: "tool-quiz", ...(input !== undefined ? { input } : {}) } as never],
|
||||||
{ type: "data-verdict", id: "vd_1", data: { accepted, at: "2026-09-12T00:00:00Z" } } as never,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The accept button's message. */
|
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||||
function planAccepted(): LhViewMessage {
|
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||||
return verdict(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEPTS; teach after", () => {
|
test("phaseAt3: probe until a concluded probe_done; plan until the first nodeId-tagged quiz; teach after", () => {
|
||||||
const probe = phaseAt3([user("teach me calculus"), assistantTool("quiz", "output-available")]);
|
const probe = phaseAt3([user("teach me calculus"), assistantQuiz({ question: "q", correctAnswer: "a" })]);
|
||||||
assert.equal(probe, "probe", "quiz answers alone never end the probe");
|
assert.equal(probe, "probe", "an UNTAGGED quiz alone never ends the probe (probe quizzes carry no nodeId)");
|
||||||
|
|
||||||
const concluded = phaseAt3([
|
const concluded = phaseAt3([
|
||||||
user("teach me"),
|
user("teach me"),
|
||||||
@@ -66,49 +59,43 @@ test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEP
|
|||||||
user("teach me"),
|
user("teach me"),
|
||||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
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"),
|
user("teach me"),
|
||||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
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");
|
assert.equal(teaching, "teach", "the first nodeId-tagged quiz CALL is where teaching begins");
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
const failedPlan = phaseAt3([
|
const failedPlan = phaseAt3([
|
||||||
user("teach me"),
|
user("teach me"),
|
||||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
assistantTool("plan_init", "output-error", { ok: false, error: "bad mermaid" }),
|
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([
|
const plainText = phaseAt3([
|
||||||
user("teach me"),
|
user("teach me"),
|
||||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
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"),
|
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", () => {
|
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("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
assistantTool("plan_init", "output-available", PLAN_OK),
|
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||||
user("what does node 1 mean?"), // side question — still planning
|
user("what does node 1 mean?"), // side question — still planning
|
||||||
planAccepted(),
|
|
||||||
user("go on"),
|
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.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "plan", "teach", "teach"]);
|
||||||
assert.equal(timeline.length, 7);
|
assert.equal(timeline.length, 8);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("phaseAt3: empty transcript is probe", () => {
|
test("phaseAt3: empty transcript is probe", () => {
|
||||||
assert.equal(phaseAt3([]), "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):
|
// The Enter decision: stop a busy stream, otherwise send. (The plan-verdict
|
||||||
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent
|
// accept branch died 2026-09-26 with the verdict itself — the post-plan
|
||||||
// a plain message, leaving the verdict pending again on the next message.
|
// reply is a natural text message.) Pure function so the static-render
|
||||||
// Pure function so the static-render harness can pin it (key events can't be
|
// harness can pin it (key events can't be simulated in renderToStaticMarkup).
|
||||||
// simulated in renderToStaticMarkup).
|
|
||||||
describe("enterAction", () => {
|
describe("enterAction", () => {
|
||||||
it("busy stream: Enter stops — verdict or not", () => {
|
it("busy stream: Enter stops", () => {
|
||||||
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop");
|
assert.equal(enterAction({ busy: true, canSend: true }), "stop");
|
||||||
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => {
|
it("idle: send when there is text, nothing when there is not", () => {
|
||||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note");
|
assert.equal(enterAction({ busy: false, canSend: true }), "send");
|
||||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict");
|
assert.equal(enterAction({ busy: false, canSend: false }), "none");
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { afterEach, describe, it } from "node:test";
|
|||||||
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
||||||
import type { ChatInput } from "../src/core/input-events.ts";
|
import type { ChatInput } from "../src/core/input-events.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.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;
|
const realFetch = globalThis.fetch;
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -589,10 +589,6 @@ describe("adoptRecord — append-only adoption", () => {
|
|||||||
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
|
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
|
||||||
"the answer result absorbs by toolCallId",
|
"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)", () => {
|
it("setMessages is the full snapshot swap (the idle settle sync)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user