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:
amosblomqvist
2026-10-06 03:47:05 +03:00
parent a2db80b72d
commit 573d89ae66
12 changed files with 246 additions and 36 deletions
+73 -15
View File
@@ -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,20 +1541,43 @@ export function Chat({
) )
} }
composer={ composer={
<Composer <div className="relative">
// Stop aborts ANY in-flight turn (stream-reattach wave): the {/* THE NEXT CHIP (owner round 16): the practice pause's
// square renders while a reattached/wake turn runs too — the mouse path — hovering at the composer band's top-right,
// abort channel kills it server-side; stop() alone would the pick-pill chrome register. One click = the move-on
// only detach our view. signal; gone the moment a problem is active again. */}
busy={busy} {nextReady && (
value={text} <button
onChange={applyText} type="button"
inputRef={composerInputRef} data-testid="next-problem-chip"
onSend={onSend} onClick={sendNext}
onStop={abortInFlightTurn} 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"
canSend={canSend} >
onEscape={dictation.active ? dictation.cancel : undefined} Next problem
/> <ArrowRightIcon className="size-3" />
</button>
)}
<Composer
// Stop aborts ANY in-flight turn (stream-reattach wave): the
// square renders while a reattached/wake turn runs too — the
// abort channel kills it server-side; stop() alone would
// only detach our view.
busy={busy}
value={text}
onChange={applyText}
inputRef={composerInputRef}
onSend={onSend}
onStop={abortInFlightTurn}
canSend={canSend}
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
View File
@@ -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}
+5
View File
@@ -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() });
+4 -2
View File
@@ -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.
+6 -5
View File
@@ -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.
+13 -2
View File
@@ -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
+22 -1
View File
@@ -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()];
}
} }
} }
+20
View File
@@ -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
+12
View File
@@ -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.
+3 -1
View File
@@ -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);
+45
View File
@@ -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",
);
});
});
+14 -1
View File
@@ -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");
});
}); });