practice: the between-problems pause — conclude ends the turn, the learner's go-ahead paces the stream (round 16)
Owner ruling (2026-10-06): every concluded problem opens a pause. The
agent may speak its closing words about the learner's last thinking
after the conclude result, then ENDS ITS TURN — a system-prompt law
deliberately, per owner correction, NOT tool-level terminate. The next
problem is never posed until the learner explicitly moves on; the pause
is the session's teaching space (repairs, clarifications, follow-ups —
still surgical, never re-teaching).
The move-on signal is a first-class input: { type: "next" } appends a
practice-next marker (content = single-sourced NEXT_PROBLEM_TEXT — the
model's unambiguous go-ahead: pose per the selection rules or wrap if
the arc is complete). Honest 400s: practice sessions only, nothing
active (mid-problem input rides the dock), riding after any pending
dismissal synthesis exactly like text.
The fast path (owner approved: composer-band chip + Enter-on-empty):
- Composer.enterAction gains "next" — Enter on an EMPTY composer in
the pause fires the signal; typing anything makes Enter a send again;
busy (steer/stop) outranks the pause.
- The Next chip hovers at the composer band's top-right (pick-pill
chrome register) whenever the pause is live and a problem has ever
been posed; the placeholder becomes the hint ("Ask about this
problem — or press Enter for the next one…", exact);
- the transcript renders the signal as a compact centered NEXT PROBLEM
line (mono-caps chrome); session-core appends it optimistically.
CONCLUDE_ACK_TEXT rewritten to carry the law; doctrine §VIII gains the
pause law (owner-sanctioned edit #3); 00-base's conclude/dismissal
bullets rewritten + the between-problems section added.
This commit is contained in:
+59
-1
@@ -33,7 +33,7 @@ import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
|||||||
import { PlanMarginalia, planExists } from "@/components/plan-window";
|
import { PlanMarginalia, planExists } from "@/components/plan-window";
|
||||||
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
||||||
import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog";
|
import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog";
|
||||||
import { DumbbellIcon, WaypointsIcon } from "lucide-react";
|
import { ArrowRightIcon, DumbbellIcon, WaypointsIcon } from "lucide-react";
|
||||||
import type { PlanBlockData } from "@/src/core";
|
import type { PlanBlockData } from "@/src/core";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
import { QuestionDock } from "@/components/question-dock";
|
||||||
import { PracticeDock } from "@/components/practice-dock";
|
import { PracticeDock } from "@/components/practice-dock";
|
||||||
@@ -634,6 +634,28 @@ export function Chat({
|
|||||||
const canSend =
|
const canSend =
|
||||||
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
|
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
|
||||||
|
|
||||||
|
/* THE PRACTICE PAUSE (owner round 16): between problems — practice
|
||||||
|
* kind, nothing active, nothing in flight, and at least one problem
|
||||||
|
* has ever been posed (the fresh session's opening turn is owed, not
|
||||||
|
* the move-on). Enter-on-empty and the Next chip both key on it. */
|
||||||
|
const nextReady =
|
||||||
|
kind === "practice" &&
|
||||||
|
!pending &&
|
||||||
|
!activeProblem &&
|
||||||
|
!compacting &&
|
||||||
|
!busy &&
|
||||||
|
nativeMessages.some(
|
||||||
|
(m) => (m as { role?: string }).role === "assistant" &&
|
||||||
|
Array.isArray((m as { content?: unknown[] }).content) &&
|
||||||
|
(m as { content: Array<{ type?: string; name?: string }> }).content.some(
|
||||||
|
(b) => b.type === "toolCall" && b.name === "problem",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const sendNext = useCallback(() => {
|
||||||
|
if (!nextReady) return;
|
||||||
|
void send({ type: "next" });
|
||||||
|
}, [nextReady, send]);
|
||||||
|
|
||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1273,6 +1295,19 @@ export function Chat({
|
|||||||
);
|
);
|
||||||
case "data-practice-thinking":
|
case "data-practice-thinking":
|
||||||
return <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
|
return <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
|
||||||
|
case "data-practice-next":
|
||||||
|
// The learner's move-on signal (owner round 16): a compact
|
||||||
|
// centered mono-caps line — chrome, the dismissal register.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pi}
|
||||||
|
data-testid="practice-next-line"
|
||||||
|
className="my-4 flex items-center justify-center gap-1.5 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-3" />
|
||||||
|
Next problem
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1506,6 +1541,22 @@ export function Chat({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composer={
|
composer={
|
||||||
|
<div className="relative">
|
||||||
|
{/* THE NEXT CHIP (owner round 16): the practice pause's
|
||||||
|
mouse path — hovering at the composer band's top-right,
|
||||||
|
the pick-pill chrome register. One click = the move-on
|
||||||
|
signal; gone the moment a problem is active again. */}
|
||||||
|
{nextReady && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="next-problem-chip"
|
||||||
|
onClick={sendNext}
|
||||||
|
className="absolute -top-3.5 right-3 z-10 flex items-center gap-1 rounded-md border border-border bg-card px-2 py-0.5 font-ui text-[10px] font-medium uppercase tracking-wider text-foreground shadow-sm transition-colors hover:border-muted-foreground/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Next problem
|
||||||
|
<ArrowRightIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Composer
|
<Composer
|
||||||
// Stop aborts ANY in-flight turn (stream-reattach wave): the
|
// Stop aborts ANY in-flight turn (stream-reattach wave): the
|
||||||
// square renders while a reattached/wake turn runs too — the
|
// square renders while a reattached/wake turn runs too — the
|
||||||
@@ -1519,7 +1570,14 @@ export function Chat({
|
|||||||
onStop={abortInFlightTurn}
|
onStop={abortInFlightTurn}
|
||||||
canSend={canSend}
|
canSend={canSend}
|
||||||
onEscape={dictation.active ? dictation.cancel : undefined}
|
onEscape={dictation.active ? dictation.cancel : undefined}
|
||||||
|
// The pause affordance (owner round 16): the placeholder
|
||||||
|
// IS the hint — empty state, exactly where Enter-on-empty
|
||||||
|
// applies.
|
||||||
|
nextProblem={nextReady}
|
||||||
|
onNext={sendNext}
|
||||||
|
placeholder={nextReady ? "Ask about this problem — or press Enter for the next one…" : undefined}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
onSubmitAnswer={(output) => {
|
onSubmitAnswer={(output) => {
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
|
|||||||
+29
-9
@@ -13,17 +13,26 @@ import { Hint } from "@/components/ui/hint";
|
|||||||
* 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: stop a busy stream,
|
/** What one Enter press does, in precedence order: stop a busy stream,
|
||||||
* otherwise send the message. (The plan-verdict accept branch died
|
* otherwise send the message, otherwise — practice sessions only — fire
|
||||||
* 2026-09-26 with the verdict itself.) Pure so the static-render harness
|
* the next-problem signal while the between-problems pause is live
|
||||||
|
* (owner round 16: Enter on an EMPTY composer is the go-ahead; typing
|
||||||
|
* anything makes Enter a send again). Pure so the static-render harness
|
||||||
* can pin the decision (key events can't be simulated in
|
* can pin the decision (key events can't be simulated in
|
||||||
* renderToStaticMarkup). */
|
* renderToStaticMarkup). */
|
||||||
export function enterAction(state: { busy: boolean; canSend: boolean }): "stop" | "send" | "none" {
|
export function enterAction(state: {
|
||||||
|
busy: boolean;
|
||||||
|
canSend: boolean;
|
||||||
|
/** The between-problems pause is live: practice session, nothing
|
||||||
|
* active, no turn/compaction in flight (the caller's fold). */
|
||||||
|
nextProblem?: boolean;
|
||||||
|
}): "stop" | "send" | "next" | "none" {
|
||||||
// Composer steering (owner arc 2026-10-03): while a turn runs, typed
|
// Composer steering (owner arc 2026-10-03): while a turn runs, typed
|
||||||
// text makes Enter a STEER (the queue-and-redirect send); Enter with
|
// text makes Enter a STEER (the queue-and-redirect send); Enter with
|
||||||
// an empty composer stays the stop shortcut. The button itself keeps
|
// an empty composer stays the stop shortcut. The button itself keeps
|
||||||
// the square — Stop stays one click away at all times.
|
// the square — Stop stays one click away at all times.
|
||||||
if (state.busy) return state.canSend ? "send" : "stop";
|
if (state.busy) return state.canSend ? "send" : "stop";
|
||||||
if (state.canSend) return "send";
|
if (state.canSend) return "send";
|
||||||
|
if (state.nextProblem) return "next";
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +47,8 @@ export function Composer({
|
|||||||
inputRef,
|
inputRef,
|
||||||
onEscape,
|
onEscape,
|
||||||
placeholder = "Ask, answer, or steer the lesson…",
|
placeholder = "Ask, answer, or steer the lesson…",
|
||||||
|
nextProblem = false,
|
||||||
|
onNext,
|
||||||
}: {
|
}: {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -56,16 +67,27 @@ export function Composer({
|
|||||||
* active sessions read "Ask, answer, or steer the lesson…"; the fresh
|
* active sessions read "Ask, answer, or steer the lesson…"; the fresh
|
||||||
* new-session page asks the graph view's opening question instead. */
|
* new-session page asks the graph view's opening question instead. */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** The practice pause is live (owner round 16): Enter on the empty
|
||||||
|
* composer fires the next-problem signal instead of doing nothing —
|
||||||
|
* the keyboard-first move-on. */
|
||||||
|
nextProblem?: boolean;
|
||||||
|
/** The signal's dispatcher (send({type:"next"})) — the chip and the
|
||||||
|
* empty-Enter share it. */
|
||||||
|
onNext?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const act = () => {
|
||||||
|
const action = enterAction({ busy, canSend, nextProblem });
|
||||||
|
if (action === "stop") onStop();
|
||||||
|
else if (action === "send") onSend();
|
||||||
|
else if (action === "next") onNext?.();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
data-testid="composer"
|
data-testid="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, canSend });
|
act();
|
||||||
if (action === "stop") onStop();
|
|
||||||
else if (action === "send") onSend();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -88,9 +110,7 @@ export function Composer({
|
|||||||
// across every input in the app (thinking field, answers).
|
// across every input in the app (thinking field, answers).
|
||||||
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, canSend });
|
act();
|
||||||
if (action === "stop") onStop();
|
|
||||||
else if (action === "send") onSend();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|||||||
import {
|
import {
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
|
newNextMessage,
|
||||||
newProblemDismissedMarker,
|
newProblemDismissedMarker,
|
||||||
newThinkingMessage,
|
newThinkingMessage,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
@@ -532,6 +533,10 @@ export class SessionModel {
|
|||||||
// optimistically — the card renders from its details the
|
// optimistically — the card renders from its details the
|
||||||
// moment the button fires.
|
// moment the button fires.
|
||||||
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.text), id: localId() });
|
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.text), id: localId() });
|
||||||
|
} else if (input.type === "next") {
|
||||||
|
// The move-on signal (owner round 16): the NEXT PROBLEM line
|
||||||
|
// renders the instant the chip/Enter fires.
|
||||||
|
optimistic.push({ ...newNextMessage(), id: localId() });
|
||||||
} 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() });
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ Two tools, and everything else is conversation. The mechanics only — the metho
|
|||||||
|
|
||||||
Only one problem may be active at a time — the tool rejects a pose while one is live. Conclude before posing the next.
|
Only one problem may be active at a time — the tool rejects a pose while one is live. Conclude before posing the next.
|
||||||
|
|
||||||
**`conclude`** — ends the active problem: records the outcome and verdict. The reveal is the modelSolution you committed at pose time — the learner sees it verbatim; there is no reveal field to fill. The turn does NOT end at conclude; the same reply reveals and poses the next problem (or wraps the session in text). The tool rejects a conclude with nothing active.
|
**`conclude`** — ends the active problem: records the outcome and verdict. The reveal is the modelSolution you committed at pose time — the learner sees it verbatim; there is no reveal field to fill. After the conclude's result you may speak your closing words about the learner's work — the read of their last thinking, what the verdict means, one earned credit — and then you END YOUR TURN: no further tool calls in that reply, never another problem. The next problem is posed only on the learner's explicit go-ahead (below). The tool rejects a conclude with nothing active.
|
||||||
|
|
||||||
**Dismissals.** The learner may discard a problem without concluding it. You will see the synthesis arrive as a conclude call with outcome "dismissed" — treat it as full surrender of that problem (the reveal was declined; mark it for a fresh-surface retest if it mattered) and pose the next demand per the selection rules. Do not comment on the dismissal beyond what the stream needs.
|
**Between problems (the pause).** Every concluded problem opens a pause, and the pause is where this session's teaching lives: the learner can ask about the problem just finished — what went wrong, why a move was right, what the hint meant. Answer fully but surgically: repairs, clarifications, follow-up questions — the teaching session's craft at practice scale, never re-teaching. Each answer ends its turn; never pose a problem at the end of one. The next problem is posed ONLY when the learner explicitly moves on — a *Next problem* message (their one-click signal), or their own words asking for the next one. On that signal, pose per the selection rules — or, if the arc is complete, wrap the session in text. A clarification question is never the go-ahead; when in doubt, answer and wait.
|
||||||
|
|
||||||
|
**Dismissals.** The learner may discard a problem without concluding it. You will see the synthesis arrive as a conclude call with outcome "dismissed" — treat it as full surrender of that problem (the reveal was declined; mark it for a fresh-surface retest if it mattered). The dismissal concludes the problem like any other: the pause law applies — no new problem until the learner's explicit go-ahead. Do not comment on the dismissal beyond what the stream needs.
|
||||||
|
|
||||||
**The rarely-needed set.** Beyond the two tools sit the learner's files (read, ls, find, grep), the research and pdf jobs, and the map readers (list_sessions, list_propositions). Reach for them only when the situation genuinely requires: the session opened without picked propositions and targets must be chosen — look at the map; the learner references a file — read it; a load-bearing fact is uncertain — research it. These are exceptions, not habits — the session's own material and its proposition list come first.
|
**The rarely-needed set.** Beyond the two tools sit the learner's files (read, ls, find, grep), the research and pdf jobs, and the map readers (list_sessions, list_propositions). Reach for them only when the situation genuinely requires: the session opened without picked propositions and targets must be chosen — look at the map; the learner references a file — read it; a load-bearing fact is uncertain — research it. These are exceptions, not habits — the session's own material and its proposition list come first.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<!-- Copied from the owner's doctrine (2026-10-04 20.37) with one approved edit:
|
<!-- Copied from the owner's doctrine (2026-10-04 20.37) with approved edits:
|
||||||
§I material-arrival generalized (verbal descriptions, outside problems).
|
§I material-arrival generalized (verbal descriptions, outside problems);
|
||||||
Tool mechanics live in 00-base.md; theory in
|
§VIII pause law (owner round 16, 2026-10-06: concluding ends the turn;
|
||||||
docs/vision-practice-sessions.md's source note. -->
|
the learner's explicit go-ahead paces the stream). Tool mechanics live
|
||||||
|
in 00-base.md; theory in docs/vision-practice-sessions.md's source note. -->
|
||||||
|
|
||||||
# The practice session
|
# The practice session
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ Classify every miss: cue miss (nothing came), discrimination error (wrong candid
|
|||||||
|
|
||||||
## VIII. Progression
|
## VIII. Progression
|
||||||
|
|
||||||
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains.
|
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
|
||||||
|
|
||||||
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
|
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
|
||||||
|
|
||||||
|
|||||||
@@ -193,9 +193,20 @@ export const THINKING_LABEL = "Your thinking";
|
|||||||
|
|
||||||
/** The conclude tool's ack — shared by the executed call (agent layer) and
|
/** The conclude tool's ack — shared by the executed call (agent layer) and
|
||||||
* the dismissal-synthesized pair (input-events), so a concluded problem
|
* the dismissal-synthesized pair (input-events), so a concluded problem
|
||||||
* reads identically to the model however it concluded. */
|
* reads identically to the model however it concluded. Owner round 16:
|
||||||
|
* the turn ENDS after concluding (a system-prompt law, no tool-level
|
||||||
|
* terminate — the model may speak its closing words first, then must
|
||||||
|
* stop calling tools); the next problem waits for the learner's
|
||||||
|
* explicit go-ahead. */
|
||||||
export const CONCLUDE_ACK_TEXT =
|
export const CONCLUDE_ACK_TEXT =
|
||||||
"Problem concluded — the composer is free; pose the next problem or wrap up.";
|
"Problem concluded. Say your closing words about the learner's work if useful, then END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, answer their questions about the concluded one.";
|
||||||
|
|
||||||
|
/** The learner's explicit move-on signal (owner round 16, 2026-10-06):
|
||||||
|
* the model-facing text of the practice-next marker — single-sourced
|
||||||
|
* like THINKING_LABEL. The button/Enter-on-empty path appends the
|
||||||
|
* marker; the model reads this and poses (or wraps) per the doctrine. */
|
||||||
|
export const NEXT_PROBLEM_TEXT =
|
||||||
|
"[NEXT PROBLEM] — the learner's explicit go-ahead: pose the next problem now per the selection rules, or wrap the session in text if the arc is complete.";
|
||||||
|
|
||||||
/** The compaction marker's model-facing header (owner round 14,
|
/** The compaction marker's model-facing header (owner round 14,
|
||||||
* 2026-10-05). The marker's content is this header + the summary — the
|
* 2026-10-05). The marker's content is this header + the summary — the
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
concludedByDismissalPair,
|
concludedByDismissalPair,
|
||||||
danglingQuestionOf,
|
danglingQuestionOf,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
|
newNextMessage,
|
||||||
newProblemDismissedMarker,
|
newProblemDismissedMarker,
|
||||||
newThinkingMessage,
|
newThinkingMessage,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
@@ -48,7 +49,12 @@ export type ChatInput =
|
|||||||
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
|
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
|
||||||
* whether it claims an answer is read from the text. One
|
* whether it claims an answer is read from the text. One
|
||||||
* problem may carry many submissions; each is its own turn. */
|
* problem may carry many submissions; each is its own turn. */
|
||||||
| { type: "thinking"; problemCallId: string; text: string };
|
| { type: "thinking"; problemCallId: string; text: string }
|
||||||
|
/** The practice move-on signal (owner round 16): the learner's
|
||||||
|
* explicit go-ahead during the between-problems pause — the Next
|
||||||
|
* chip or Enter on an empty composer. Practice sessions only, and
|
||||||
|
* only with nothing active (mid-problem input rides the dock). */
|
||||||
|
| { type: "next" };
|
||||||
|
|
||||||
/** A wake-pipeline steer delivery — same turn composition, server-origin.
|
/** A wake-pipeline steer delivery — same turn composition, server-origin.
|
||||||
* Wave C (2026-09-26): the idle wake carries EVERY queued marker as its
|
* Wave C (2026-09-26): the idle wake carries EVERY queued marker as its
|
||||||
@@ -172,6 +178,8 @@ export function parseChatInput(input: unknown): ChatInput {
|
|||||||
throw new ChatInputError("thinking input requires non-empty text");
|
throw new ChatInputError("thinking input requires non-empty text");
|
||||||
}
|
}
|
||||||
return { type: "thinking", problemCallId: i.problemCallId, text: i.text };
|
return { type: "thinking", problemCallId: i.problemCallId, text: i.text };
|
||||||
|
case "next":
|
||||||
|
return { type: "next" };
|
||||||
default:
|
default:
|
||||||
throw new ChatInputError(`unknown input type: ${String(i.type)}`);
|
throw new ChatInputError(`unknown input type: ${String(i.type)}`);
|
||||||
}
|
}
|
||||||
@@ -206,6 +214,19 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
|
|||||||
}
|
}
|
||||||
return [...dismissalConclusion(record), newThinkingMessage(input.problemCallId, input.text)];
|
return [...dismissalConclusion(record), newThinkingMessage(input.problemCallId, input.text)];
|
||||||
}
|
}
|
||||||
|
case "next": {
|
||||||
|
// Honest 400s, the thinking gate's discipline: the signal is a
|
||||||
|
// between-problems affordance — practice sessions only, nothing
|
||||||
|
// active (mid-problem input rides the dock), and the client gates
|
||||||
|
// make violations unreachable in practice anyway.
|
||||||
|
if ((record as { kind?: string }).kind !== "practice") {
|
||||||
|
throw new ChatInputError("next input is a practice-session affordance");
|
||||||
|
}
|
||||||
|
if (activeProblemOf(record.messages)) {
|
||||||
|
throw new ChatInputError("next input requires no active problem");
|
||||||
|
}
|
||||||
|
return [...dismissalConclusion(record), newNextMessage()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { QUESTION_TOOLS } from "./pending.ts";
|
|||||||
import {
|
import {
|
||||||
CONCLUDE_ACK_TEXT,
|
CONCLUDE_ACK_TEXT,
|
||||||
HANDOFF_TEXT,
|
HANDOFF_TEXT,
|
||||||
|
NEXT_PROBLEM_TEXT,
|
||||||
THINKING_LABEL,
|
THINKING_LABEL,
|
||||||
} from "./contracts/chat.ts";
|
} from "./contracts/chat.ts";
|
||||||
import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts";
|
import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts";
|
||||||
@@ -71,6 +72,7 @@ export interface LhMarkerMessage {
|
|||||||
| "subagent-abort"
|
| "subagent-abort"
|
||||||
| "plan-block"
|
| "plan-block"
|
||||||
| "practice-thinking"
|
| "practice-thinking"
|
||||||
|
| "practice-next"
|
||||||
| "problem-dismissed"
|
| "problem-dismissed"
|
||||||
| "compaction";
|
| "compaction";
|
||||||
/** The model-facing text ("" for plan blocks — UI-only). */
|
/** The model-facing text ("" for plan blocks — UI-only). */
|
||||||
@@ -248,6 +250,24 @@ export function newThinkingMessage(problemCallId: string, text: string): LhMarke
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The learner's explicit move-on signal (owner round 16): appended by
|
||||||
|
* the next input (the composer band's chip, or Enter on an empty
|
||||||
|
* composer during the between-problems pause). The content is the
|
||||||
|
* single-sourced NEXT_PROBLEM_TEXT — the model's unambiguous go-ahead;
|
||||||
|
* the transcript renders its own compact line off the marker (never
|
||||||
|
* the content). */
|
||||||
|
export function newNextMessage(): LhMarkerMessage {
|
||||||
|
return {
|
||||||
|
id: newMessageId(),
|
||||||
|
role: "custom",
|
||||||
|
customType: "practice-next",
|
||||||
|
content: NEXT_PROBLEM_TEXT,
|
||||||
|
display: true,
|
||||||
|
details: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The problem-dismissal marker (ask-style discard, owner 2026-10-04):
|
/** The problem-dismissal marker (ask-style discard, owner 2026-10-04):
|
||||||
* UI-ONLY — toLlmMessages filters it. The model's face arrives later, at
|
* UI-ONLY — toLlmMessages filters it. The model's face arrives later, at
|
||||||
* the user's next input, as the synthesized conclude pair
|
* the user's next input, as the synthesized conclude pair
|
||||||
|
|||||||
@@ -401,6 +401,18 @@ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "practice-next": {
|
||||||
|
// The learner's explicit move-on signal (owner round 16): a compact
|
||||||
|
// centered system line — the mono-caps chrome register, not prose.
|
||||||
|
// The model reads the marker's content; the transcript never shows
|
||||||
|
// it (the label is chrome, single-sourced in the renderer).
|
||||||
|
out.push({
|
||||||
|
id: marker.id,
|
||||||
|
role: "user",
|
||||||
|
parts: [{ type: "data-practice-next", id: `pn_${marker.id}`, data: null }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "problem-dismissed":
|
case "problem-dismissed":
|
||||||
// UI-only (the ask-style dismissal): the synthesized conclude
|
// UI-only (the ask-style dismissal): the synthesized conclude
|
||||||
// pair — which rides the NEXT input — carries the record's face.
|
// pair — which rides the NEXT input — carries the record's face.
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ describe("practice flow (scripted model)", () => {
|
|||||||
(m) => (m as ToolResultMessage).role === "toolResult" && (m as ToolResultMessage).toolName === "conclude",
|
(m) => (m as ToolResultMessage).role === "toolResult" && (m as ToolResultMessage).toolName === "conclude",
|
||||||
) as LhMessage & ToolResultMessage;
|
) as LhMessage & ToolResultMessage;
|
||||||
assert.ok(concludeAck, "the conclude ack persisted");
|
assert.ok(concludeAck, "the conclude ack persisted");
|
||||||
assert.ok((concludeAck.content[0] as { text: string }).text.includes("composer is free"));
|
// Owner round 16: the ack carries the turn-ends law (no more tools,
|
||||||
|
// the learner's go-ahead paces the next problem).
|
||||||
|
assert.ok((concludeAck.content[0] as { text: string }).text.includes("END YOUR TURN"));
|
||||||
// The turn continued past conclude to the reveal text.
|
// The turn continued past conclude to the reveal text.
|
||||||
assert.ok(afterConclude.some((m) => (m as { role?: string }).role === "assistant" && JSON.stringify((m as { content?: unknown }).content).includes("Clean proof.")));
|
assert.ok(afterConclude.some((m) => (m as { role?: string }).role === "assistant" && JSON.stringify((m as { content?: unknown }).content).includes("Clean proof.")));
|
||||||
assert.equal(activeProblemOf(afterConclude), null);
|
assert.equal(activeProblemOf(afterConclude), null);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
asAgentMessages,
|
asAgentMessages,
|
||||||
concludedByDismissalPair,
|
concludedByDismissalPair,
|
||||||
newProblemDismissedMarker,
|
newProblemDismissedMarker,
|
||||||
|
newNextMessage,
|
||||||
newThinkingMessage,
|
newThinkingMessage,
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
@@ -233,6 +234,37 @@ describe("the thinking input", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── the move-on signal (owner round 16, 2026-10-06) ─────────────────────────
|
||||||
|
|
||||||
|
describe("the next input", () => {
|
||||||
|
it("parses bare and appends the practice-next marker in an idle practice session", () => {
|
||||||
|
const input = parseChatInput({ type: "next" });
|
||||||
|
assert.deepEqual(input, { type: "next" });
|
||||||
|
const appends = inputAppends({ messages: [], kind: "practice" }, input);
|
||||||
|
assert.equal(appends.length, 1);
|
||||||
|
const marker = appends[0] as { customType: string; content: string };
|
||||||
|
assert.equal(marker.customType, "practice-next");
|
||||||
|
// The model's unambiguous go-ahead, single-sourced in contracts.
|
||||||
|
assert.match(marker.content, /\[NEXT PROBLEM\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("400s outside practice sessions (the affordance is practice-only)", () => {
|
||||||
|
assert.throws(() => inputAppends({ messages: [], kind: "teach" }, { type: "next" }), /practice-session/);
|
||||||
|
assert.throws(() => inputAppends({ messages: [] }, { type: "next" }), /practice-session/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("400s while a problem is active (mid-problem input rides the dock)", () => {
|
||||||
|
assert.throws(() => inputAppends({ messages: problemPose(), kind: "practice" }, { type: "next" }), /no active problem/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rides AFTER a pending dismissal conclusion, exactly like text", () => {
|
||||||
|
const dismissed = [...problemPose(), newProblemDismissedMarker("call_p1")];
|
||||||
|
const appends = inputAppends({ messages: dismissed, kind: "practice" }, { type: "next" });
|
||||||
|
assert.equal(appends.length, 3); // conclude pair + the next marker
|
||||||
|
assert.equal((appends[2] as { customType: string }).customType, "practice-next");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the dismissal's conclude synthesis rides the next input", () => {
|
describe("the dismissal's conclude synthesis rides the next input", () => {
|
||||||
it("text after a dismissal prepends the conclude pair before the user message", () => {
|
it("text after a dismissal prepends the conclude pair before the user message", () => {
|
||||||
const record = { messages: [...problemPose(), newProblemDismissedMarker("call_p1")] };
|
const record = { messages: [...problemPose(), newProblemDismissedMarker("call_p1")] };
|
||||||
@@ -374,3 +406,16 @@ describe("modelSolutionForConclude (the conclusion's hero is the pose's commitme
|
|||||||
assert.equal(conclude!.reveal, "Show that…", "the view-seam join carries the committed solution");
|
assert.equal(conclude!.reveal, "Show that…", "the view-seam join carries the committed solution");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── the transcript face of the signal ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe("projectView renders the move-on signal", () => {
|
||||||
|
it("the practice-next marker projects the compact part (the centered NEXT PROBLEM line)", () => {
|
||||||
|
const view = projectView([newNextMessage()]);
|
||||||
|
const parts = view[0]?.parts ?? [];
|
||||||
|
assert.ok(
|
||||||
|
parts.some((p) => p.type === "data-practice-next"),
|
||||||
|
"the data-practice-next part rides the view",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,8 +75,21 @@ describe("enterAction", () => {
|
|||||||
assert.equal(enterAction({ busy: true, canSend: false }), "stop");
|
assert.equal(enterAction({ busy: true, canSend: false }), "stop");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("idle: send when there is text, nothing when there is not", () => {
|
it("idle: send when there is the text, nothing when there is not", () => {
|
||||||
assert.equal(enterAction({ busy: false, canSend: true }), "send");
|
assert.equal(enterAction({ busy: false, canSend: true }), "send");
|
||||||
assert.equal(enterAction({ busy: false, canSend: false }), "none");
|
assert.equal(enterAction({ busy: false, canSend: false }), "none");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the practice pause (owner round 16): Enter on an EMPTY composer fires the move-on signal", () => {
|
||||||
|
assert.equal(enterAction({ busy: false, canSend: false, nextProblem: true }), "next");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing anything outranks the pause — text makes Enter a send again", () => {
|
||||||
|
assert.equal(enterAction({ busy: false, canSend: true, nextProblem: true }), "send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("busy outranks the pause — steering and Stop keep their lanes", () => {
|
||||||
|
assert.equal(enterAction({ busy: true, canSend: false, nextProblem: true }), "stop");
|
||||||
|
assert.equal(enterAction({ busy: true, canSend: true, nextProblem: true }), "send");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user