practice: ONE send lane — the register is read from the text
Owner round 2 (2026-10-05), Slice H: the Nudge/Commit two-lane buttons are gone. One Send; Enter sends, Shift+Enter is a newline. Whether a submission claims an answer is apparent from the thinking itself — the agent reads it (hint ladder for traces, moves+verdict for claims). - The thinking input loses the commit field end-to-end: ChatInput, parseChatInput, newThinkingMessage (details and the model-facing label both collapse to the single 'Your thinking'), the optimistic marker. - Legacy records' commit flags are deliberately unread. - Doctrine: the two labeled channels become one read instruction; 'answer commits' section retitled 'Claimed answers' — a claim is not a progress signal. - ThinkingCard: one label, collapsed arrival (the unified row). - The dock placeholder no longer explains lanes that no longer exist.
This commit is contained in:
+8
-8
@@ -448,16 +448,16 @@ export function Chat({
|
||||
void resumeStream();
|
||||
}, [resumeStream]);
|
||||
|
||||
// The practice dock's two lanes (owner, 2026-10-04): both POST one
|
||||
// thinking input against the ACTIVE problem — commit=false is the
|
||||
// nudge (hint request), commit=true the answer commit. Each starts a
|
||||
// turn; the dock stays mounted (the problem is still active until the
|
||||
// agent concludes).
|
||||
// The practice dock's send (ONE lane, owner 2026-10-05): POSTs the
|
||||
// thinking against the ACTIVE problem — whether it claims an answer is
|
||||
// read from the text by the agent, not declared here. Starts a turn;
|
||||
// the dock stays mounted (the problem is still active until the agent
|
||||
// concludes).
|
||||
const submitThinking = useCallback(
|
||||
(commit: boolean, text: string) => {
|
||||
(text: string) => {
|
||||
const problem = activeProblemRef.current;
|
||||
if (!problem) return;
|
||||
void send({ type: "thinking", problemCallId: problem.toolCallId, commit, text });
|
||||
void send({ type: "thinking", problemCallId: problem.toolCallId, text });
|
||||
},
|
||||
[send],
|
||||
);
|
||||
@@ -1631,7 +1631,7 @@ function DockOverlay({
|
||||
* question (the composer is replaced); null in teaching sessions. */
|
||||
practice: { toolCallId: string; input: unknown } | null;
|
||||
/** (commit, text) — the practice dock's two submit lanes. */
|
||||
onSubmitThinking: (commit: boolean, text: string) => void;
|
||||
onSubmitThinking: (text: string) => void;
|
||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||
requiresReveal: boolean;
|
||||
/** The reading-thinking draft (owner 2026-10-03) — owned by ChatView so
|
||||
|
||||
@@ -24,7 +24,8 @@ import { ThinkingBlock } from "@/components/thinking-block";
|
||||
/** The practice-thinking marker's details (native-messages.ts). */
|
||||
export interface PracticeThinkingData {
|
||||
problemCallId: string;
|
||||
commit: boolean;
|
||||
/** Legacy records (pre 2026-10-05) carry the old two-lane flag; ignored. */
|
||||
commit?: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -104,16 +105,8 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
||||
}
|
||||
|
||||
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
|
||||
// The unified row (owner round, 2026-10-05): one style — collapsed
|
||||
// arrival, 50%-italic quote block on expand. The register survives only
|
||||
// in the label ("Your solution" when committed) and the data-commit
|
||||
// flag for the tests.
|
||||
return (
|
||||
<ThinkingBlock
|
||||
testId="thinking-card"
|
||||
dataCommit={data.commit}
|
||||
label={data.commit ? "Your solution" : "Your thinking"}
|
||||
text={data.text}
|
||||
/>
|
||||
);
|
||||
// The unified row (owner rounds, 2026-10-05): one style, one label —
|
||||
// collapsed arrival, "Your thinking", 50%-italic quote block on expand.
|
||||
// Legacy records' commit flag is deliberately unread (one lane now).
|
||||
return <ThinkingBlock testId="thinking-card" label="Your thinking" text={data.text} />;
|
||||
}
|
||||
|
||||
@@ -10,16 +10,11 @@ import { cn } from "@/lib/utils";
|
||||
* The practice dock (practice sessions, owner design 2026-10-04): an
|
||||
* ACTIVE problem replaces the chat composer — the persistent bottom blob
|
||||
* morphs into the problem. The agent's statement above; the learner's
|
||||
* half below — "Your thinking", a labeled composer-twin field (the
|
||||
* question dock's discipline: Enter makes a newline, it is for dumping)
|
||||
* with TWO submit buttons:
|
||||
*
|
||||
* - **Nudge** — submit the thinking WITHOUT a committed answer: working
|
||||
* trace, hint request (the ladder answers, one rung per request).
|
||||
* - **Commit** — the thinking contains an arrived answer.
|
||||
*
|
||||
* ⌘/Ctrl+Enter commits (the universal submit chord). One problem may
|
||||
* carry many submissions; each starts a turn.
|
||||
* half below — "Your thinking", a labeled field with ONE Send button
|
||||
* (owner round 2, 2026-10-05): Enter sends, Shift+Enter makes a newline.
|
||||
* There are no lanes — whether a submission claims an answer is apparent
|
||||
* from the thinking itself; the agent reads it. One problem may carry
|
||||
* many submissions; each starts a turn.
|
||||
*
|
||||
* Collapse (scroll-up → slim bar) is owned by the DockOverlay; this
|
||||
* component is content only, and the expanded form stays MOUNTED while
|
||||
@@ -40,8 +35,8 @@ export interface PracticeDockProps {
|
||||
/** Collapse state, owned by the DockOverlay. */
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
/** (commit, text) — POST {type:"thinking"}; commit=false is the nudge. */
|
||||
onSubmit: (commit: boolean, text: string) => void;
|
||||
/** text — POST {type:"thinking"} (one lane; the register is read from the text). */
|
||||
onSubmit: (text: string) => void;
|
||||
/** Escape in the thinking field stops dictation (threaded from the page). */
|
||||
onEscape?: () => void;
|
||||
}
|
||||
@@ -65,12 +60,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
true,
|
||||
);
|
||||
|
||||
const submit = (commit: boolean) => {
|
||||
const submit = () => {
|
||||
const text = thinkingRef.current.trim();
|
||||
if (text.length === 0) return;
|
||||
thinkingRef.current = "";
|
||||
setThinking("");
|
||||
onSubmit(commit, text);
|
||||
onSubmit(text);
|
||||
};
|
||||
|
||||
/* ── the collapsed bar ── */
|
||||
@@ -103,8 +98,8 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The learner's half — a composer TWIN (the question dock's
|
||||
discipline): Enter makes a newline; the buttons submit. */}
|
||||
{/* The learner's half: Enter SENDS (owner round 2, 2026-10-05);
|
||||
Shift+Enter makes a newline — less to decide about. */}
|
||||
<div
|
||||
data-testid="practice-thinking-field"
|
||||
className="m-1.5 rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||
@@ -127,39 +122,30 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
// ⌘↵ on macOS, Ctrl↵ elsewhere — the universal submit
|
||||
// chord; here it COMMITS (the stronger of the two acts).
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
// Enter SENDS — the single lane (owner round 2, 2026-10-05);
|
||||
// Shift+Enter is the newline, ⌘/Ctrl+Enter the chord too.
|
||||
if (e.key === "Enter" && (!e.shiftKey || e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit(true);
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="Work here — say as much of your thinking as you have. Nudge asks for a hint without committing; Commit submits an answer."
|
||||
placeholder="Work here — say as much of your thinking as you have."
|
||||
rows={4}
|
||||
className={cn(
|
||||
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{/* The two submit lanes — docked inside the field like the
|
||||
composer's send, right-aligned. */}
|
||||
<div className="flex items-center justify-end gap-1.5 px-1.5 pb-1.5">
|
||||
{/* The one Send — docked inside the field like the composer's,
|
||||
right-aligned. */}
|
||||
<div className="flex items-center justify-end px-1.5 pb-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="practice-nudge"
|
||||
data-testid="practice-send"
|
||||
disabled={thinking.trim().length === 0}
|
||||
onClick={() => submit(false)}
|
||||
className="rounded-lg border border-border/70 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
Nudge
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="practice-commit"
|
||||
disabled={thinking.trim().length === 0}
|
||||
onClick={() => submit(true)}
|
||||
onClick={() => submit()}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
Commit
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -529,9 +529,9 @@ export class SessionModel {
|
||||
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), id: localId() });
|
||||
else if (input.type === "thinking") {
|
||||
// The practice submissions (2026-10-04): the marker lands
|
||||
// optimistically — the register (trace / commit) renders from
|
||||
// its details the moment the button fires.
|
||||
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.commit, input.text), id: localId() });
|
||||
// optimistically — the card renders from its details the
|
||||
// moment the button fires.
|
||||
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.text), id: localId() });
|
||||
} else if (input.type === "answer") {
|
||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
||||
|
||||
@@ -23,10 +23,10 @@ He has to be able to trust you completely; one confidently-delivered hallucinati
|
||||
|
||||
Two tools, and everything else is conversation. The mechanics only — the method (when to pose, what to pose, how to respond) is the doctrine's.
|
||||
|
||||
**`problem`** — poses one problem. The statement field is what the learner sees (markdown and LaTeX render); the modelSolution is your private commitment, hidden from the learner until you conclude. The call ends the turn. The learner then works in a thinking field; their submissions arrive back as labeled messages:
|
||||
**`problem`** — poses one problem. The statement field is what the learner sees (markdown and LaTeX render); the modelSolution is your private commitment, hidden from the learner until you conclude. The call ends the turn. The learner then works in a thinking field; each submission arrives back as a *Your thinking* message. Whether it claims an answer is IN THE TEXT, not declared anywhere — read it:
|
||||
|
||||
- *Thinking trace (working, not committed):* they are mid-work and asking for a nudge. This is a hint request — the hint ladder applies, one rung per request. A trace may span several of these.
|
||||
- *Committed answer:* they claim an answer. Extract the moves, deliver the verdict. Remember: a commit is not a progress signal.
|
||||
- Working trace (mid-work, stuck, or asking for a nudge): the hint ladder applies, one rung per request. A trace may span several submissions.
|
||||
- A claimed answer: extract the moves, deliver the verdict. A claim is not a progress signal.
|
||||
|
||||
Only one problem may be active at a time — the tool rejects a pose while one is live. Conclude before posing the next.
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ The statement never announces its target. If the situation makes the applicable
|
||||
|
||||
## VI. Hints
|
||||
|
||||
The policy covers every hint request and every intermediate submission that is not an answer commit — including a bare "I'm stuck" with an empty trace, where there is nothing to diagnose and you therefore start at the top of the ladder.
|
||||
The policy covers every submission that does not claim an answer — including a bare "I'm stuck" with an empty trace, where there is nothing to diagnose and you therefore start at the top of the ladder.
|
||||
|
||||
The economics first: revealed information cannot be unrevealed, and the learner can always think longer and ask again. So help is held to the minimum — regardless of how much the learner is struggling.
|
||||
|
||||
@@ -104,7 +104,7 @@ Three structural rules:
|
||||
|
||||
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
|
||||
|
||||
## VII. Answer commits
|
||||
## VII. Claimed answers
|
||||
|
||||
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
|
||||
|
||||
@@ -116,7 +116,7 @@ Classify every miss: cue miss (nothing came), discrimination error (wrong candid
|
||||
|
||||
## VIII. Progression
|
||||
|
||||
An answer commit is not a signal to progress. You own progression, not the commit count. A problem may take many commits; 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -136,5 +136,5 @@ If the learner brings a problem from outside — an exam problem, something they
|
||||
- Never reveal what the learner can still generate — one rung per request, verification before correction.
|
||||
- Never re-ask in the same surface.
|
||||
- Never teach beyond a surgical line.
|
||||
- Never mistake a commit for a progress signal.
|
||||
- Never mistake a claimed answer for a progress signal.
|
||||
- Never soften a verdict or inflate one.
|
||||
|
||||
@@ -186,8 +186,10 @@ export const HANDOFF_TEXT =
|
||||
/** The model-facing register of a thinking submission (newThinkingMessage
|
||||
* prefixes the learner's text with this label). Hints and commits ride
|
||||
* one channel — the label is the ONLY difference the model sees. */
|
||||
export const THINKING_HINT_LABEL = "Thinking trace (working, not committed)";
|
||||
export const THINKING_COMMIT_LABEL = "Committed answer";
|
||||
/** The single submission label (owner round, 2026-10-05): one send
|
||||
* lane — whether a submission claims an answer is read from the text,
|
||||
* not declared by a button. */
|
||||
export const THINKING_LABEL = "Your thinking";
|
||||
|
||||
/** The conclude tool's ack — shared by the executed call (agent layer) and
|
||||
* the dismissal-synthesized pair (input-events), so a concluded problem
|
||||
|
||||
@@ -45,10 +45,10 @@ export type ChatInput =
|
||||
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
||||
| { type: "continue" }
|
||||
/** The practice dock's two buttons (owner, 2026-10-04): the learner's
|
||||
* thinking on the ACTIVE problem — commit=false is the hint request
|
||||
* (working, not committed), commit=true the answer commit. One
|
||||
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
|
||||
* whether it claims an answer is read from the text. One
|
||||
* problem may carry many submissions; each is its own turn. */
|
||||
| { type: "thinking"; problemCallId: string; commit: boolean; text: string };
|
||||
| { type: "thinking"; problemCallId: string; text: string };
|
||||
|
||||
/** A wake-pipeline steer delivery — same turn composition, server-origin.
|
||||
* Wave C (2026-09-26): the idle wake carries EVERY queued marker as its
|
||||
@@ -168,13 +168,10 @@ export function parseChatInput(input: unknown): ChatInput {
|
||||
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
|
||||
throw new ChatInputError("thinking input requires problemCallId");
|
||||
}
|
||||
if (typeof i.commit !== "boolean") {
|
||||
throw new ChatInputError("thinking input requires commit (boolean)");
|
||||
}
|
||||
if (typeof i.text !== "string" || i.text.trim().length === 0) {
|
||||
throw new ChatInputError("thinking input requires non-empty text");
|
||||
}
|
||||
return { type: "thinking", problemCallId: i.problemCallId, commit: i.commit, text: i.text };
|
||||
return { type: "thinking", problemCallId: i.problemCallId, text: i.text };
|
||||
default:
|
||||
throw new ChatInputError(`unknown input type: ${String(i.type)}`);
|
||||
}
|
||||
@@ -207,7 +204,7 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
|
||||
if (!active || active.toolCallId !== input.problemCallId) {
|
||||
throw new ChatInputError("thinking input targets no active problem");
|
||||
}
|
||||
return [...dismissalConclusion(record), newThinkingMessage(input.problemCallId, input.commit, input.text)];
|
||||
return [...dismissalConclusion(record), newThinkingMessage(input.problemCallId, input.text)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import {
|
||||
CONCLUDE_ACK_TEXT,
|
||||
HANDOFF_TEXT,
|
||||
THINKING_COMMIT_LABEL,
|
||||
THINKING_HINT_LABEL,
|
||||
THINKING_LABEL,
|
||||
} from "./contracts/chat.ts";
|
||||
import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts";
|
||||
|
||||
@@ -224,20 +223,20 @@ export function newGraphContextMessage(picks: readonly GraphContextPick[], chain
|
||||
};
|
||||
}
|
||||
|
||||
/** The learner's thinking submission (the practice dock's two buttons):
|
||||
* a user-register message the model reads WITH its label (hint vs
|
||||
* commit — the register is the whole difference), while `details` keeps
|
||||
* the raw payload for the transcript card. One problem may carry many
|
||||
/** The learner's thinking submission (ONE send lane, owner round
|
||||
* 2026-10-05): the register is no longer declared by a button — the
|
||||
* model READS the text to tell working traces from claimed answers. The
|
||||
* content label is the single "Your thinking"; `details` keeps the raw
|
||||
* payload for the transcript card. One problem may carry many
|
||||
* submissions. */
|
||||
export function newThinkingMessage(problemCallId: string, commit: boolean, text: string): LhMarkerMessage {
|
||||
const label = commit ? THINKING_COMMIT_LABEL : THINKING_HINT_LABEL;
|
||||
export function newThinkingMessage(problemCallId: string, text: string): LhMarkerMessage {
|
||||
return {
|
||||
id: newMessageId(),
|
||||
role: "custom",
|
||||
customType: "practice-thinking",
|
||||
content: `${label}:\n${text}`,
|
||||
content: `${THINKING_LABEL}:\n${text}`,
|
||||
display: true,
|
||||
details: { problemCallId, commit, text },
|
||||
details: { problemCallId, text },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,15 +137,16 @@ describe("practice flow (scripted model)", () => {
|
||||
assert.equal(needsContinuation(afterPose), false);
|
||||
assert.equal(activeProblemOf(afterPose)?.toolCallId, "call-p1");
|
||||
|
||||
// ── Turn 2: the commit arrives as a labeled thinking message ──
|
||||
// ── Turn 2: the submission arrives as a single-lane thinking message ──
|
||||
await drain(
|
||||
await handleChatRequest(
|
||||
post(chat.id, { type: "thinking", problemCallId: "call-p1", commit: true, text: "|G|=p; ⟨x⟩ has order dividing p" }),
|
||||
post(chat.id, { type: "thinking", problemCallId: "call-p1", text: "|G|=p; ⟨x⟩ has order dividing p" }),
|
||||
factory,
|
||||
),
|
||||
);
|
||||
// The model saw the COMMIT label, not the hint label.
|
||||
assert.ok(lastSaw(calls, "Committed answer:"), "the conclude turn's prompt carries the committed answer");
|
||||
// The model saw the one label (owner round 2, 2026-10-05): the
|
||||
// register is read from the text, not declared by a button.
|
||||
assert.ok(lastSaw(calls, "Your thinking:"), "the conclude turn's prompt carries the thinking");
|
||||
const afterConclude = chatStore.getChat(chat.id)!.messages;
|
||||
const concludeAck = afterConclude.find(
|
||||
(m) => (m as ToolResultMessage).role === "toolResult" && (m as ToolResultMessage).toolName === "conclude",
|
||||
@@ -157,7 +158,7 @@ describe("practice flow (scripted model)", () => {
|
||||
assert.equal(activeProblemOf(afterConclude), null);
|
||||
});
|
||||
|
||||
it("the nudge (commit:false) carries the hint label", async () => {
|
||||
it("a working-trace submission keeps the problem active", async () => {
|
||||
const chat = createPracticeChat("Test: practice nudge");
|
||||
const { engine, calls } = scripted([
|
||||
() => fauxAssistantMessage([fauxToolCall("problem", POSE, { id: "call-p2" })], { stopReason: "toolUse" }),
|
||||
@@ -167,12 +168,12 @@ describe("practice flow (scripted model)", () => {
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "go" }), factory));
|
||||
await drain(
|
||||
await handleChatRequest(
|
||||
post(chat.id, { type: "thinking", problemCallId: "call-p2", commit: false, text: "stuck at the second step" }),
|
||||
post(chat.id, { type: "thinking", problemCallId: "call-p2", text: "stuck at the second step" }),
|
||||
factory,
|
||||
),
|
||||
);
|
||||
assert.ok(lastSaw(calls, "Thinking trace (working, not committed):"), "the nudge turn's prompt carries the hint label");
|
||||
// The problem is still active — a nudge never concludes.
|
||||
assert.ok(lastSaw(calls, "Your thinking:"), "the turn's prompt carries the thinking");
|
||||
// The problem is still active — a trace never concludes.
|
||||
assert.equal(activeProblemOf(chatStore.getChat(chat.id)!.messages)?.toolCallId, "call-p2");
|
||||
});
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("activeProblemOf (the conclude-before-next-problem gate's fold)", () =>
|
||||
it("thinking submissions keep it active (nudges never conclude)", () => {
|
||||
const messages = [
|
||||
...problemPose(),
|
||||
newThinkingMessage("call_p1", false, "stuck at the second step"),
|
||||
newThinkingMessage("call_p1", "stuck at the second step"),
|
||||
];
|
||||
assert.equal(activeProblemOf(messages)?.toolCallId, "call_p1");
|
||||
});
|
||||
@@ -193,52 +193,43 @@ describe("pendingProblemDismissalOf (the marker waiting for its synthesis)", ()
|
||||
// ── the input channel ────────────────────────────────────────────────────────
|
||||
|
||||
describe("the thinking input", () => {
|
||||
it("parses and appends the marker against the active problem", () => {
|
||||
it("parses and appends the marker against the active problem (ONE lane)", () => {
|
||||
const input = parseChatInput({
|
||||
type: "thinking",
|
||||
problemCallId: "call_p1",
|
||||
commit: false,
|
||||
text: "I think the inactivity constraint applies here",
|
||||
});
|
||||
assert.deepEqual(input, {
|
||||
type: "thinking",
|
||||
problemCallId: "call_p1",
|
||||
commit: false,
|
||||
text: "I think the inactivity constraint applies here",
|
||||
});
|
||||
const appends = inputAppends({ messages: problemPose() }, input);
|
||||
assert.equal(appends.length, 1);
|
||||
const marker = appends[0] as { customType: string; content: string; details: unknown };
|
||||
assert.equal(marker.customType, "practice-thinking");
|
||||
assert.match(marker.content, /Thinking trace \(working, not committed\):\n/);
|
||||
// The single label (owner round 2, 2026-10-05): the register is read
|
||||
// from the text by the agent, never declared by a button.
|
||||
assert.match(marker.content, /Your thinking:\n/);
|
||||
assert.deepEqual(marker.details, {
|
||||
problemCallId: "call_p1",
|
||||
commit: false,
|
||||
text: "I think the inactivity constraint applies here",
|
||||
});
|
||||
});
|
||||
|
||||
it("commit=true carries the committed register", () => {
|
||||
const appends = inputAppends(
|
||||
{ messages: problemPose() },
|
||||
{ type: "thinking", problemCallId: "call_p1", commit: true, text: "The answer is 42 because…" },
|
||||
);
|
||||
assert.match((appends[0] as { content: string }).content, /Committed answer:\n/);
|
||||
});
|
||||
|
||||
it("400s against a stale or unknown problem call", () => {
|
||||
assert.throws(
|
||||
() => inputAppends({ messages: problemPose() }, { type: "thinking", problemCallId: "call_ghost", commit: true, text: "x" }),
|
||||
() => inputAppends({ messages: problemPose() }, { type: "thinking", problemCallId: "call_ghost", text: "x" }),
|
||||
/targets no active problem/,
|
||||
);
|
||||
assert.throws(
|
||||
() => inputAppends({ messages: [] }, { type: "thinking", problemCallId: "call_p1", commit: true, text: "x" }),
|
||||
() => inputAppends({ messages: [] }, { type: "thinking", problemCallId: "call_p1", text: "x" }),
|
||||
/targets no active problem/,
|
||||
);
|
||||
});
|
||||
|
||||
it("empty text 400s at parse", () => {
|
||||
assert.throws(() => parseChatInput({ type: "thinking", problemCallId: "c", commit: false, text: " " }), /non-empty/);
|
||||
assert.throws(() => parseChatInput({ type: "thinking", problemCallId: "c", text: " " }), /non-empty/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -289,11 +280,11 @@ describe("the dismiss-problem input (ask-style discard)", () => {
|
||||
|
||||
describe("toLlmMessages (practice shapes)", () => {
|
||||
it("the thinking marker flattens to a labeled user message", () => {
|
||||
const out = toLlmMessages(asAgentMessages([newThinkingMessage("call_p1", true, "It is 42")]));
|
||||
const out = toLlmMessages(asAgentMessages([newThinkingMessage("call_p1", "It is 42")]));
|
||||
assert.equal(out.length, 1);
|
||||
const user = out[0] as { role: string; content: Array<{ type: string; text: string }> };
|
||||
assert.equal(user.role, "user");
|
||||
assert.match(user.content[0]!.text, /^Committed answer:\nIt is 42$/);
|
||||
assert.match(user.content[0]!.text, /^Your thinking:\nIt is 42$/);
|
||||
});
|
||||
|
||||
it("the dismissal marker is UI-only (the synthesized pair carries the face)", () => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* THE PRACTICE SURFACE — SSR render pins (the render-suite discipline:
|
||||
* pure bodies, renderToStaticMarkup, testids). The dock (the active
|
||||
* problem replaces the composer: statement above, "Your thinking"
|
||||
* composer-twin below, the TWO submit lanes — Nudge without a commit,
|
||||
* Commit with one; ⌘/Ctrl+Enter commits), the transcript cards (the
|
||||
* field below with ONE Send (Enter sends, Shift+Enter is a newline)),
|
||||
* the transcript cards (the
|
||||
* problem's compact-expandable statement — commitment NEVER renders;
|
||||
* the conclusion's outcome + verdict + reveal; the learner's thinking
|
||||
* register cards), and the view projection (the thinking marker's
|
||||
@@ -56,8 +56,8 @@ describe("PracticeDock", () => {
|
||||
// The measured column cap (the old fixed 40vh is only the fallback).
|
||||
assert.match(html, /--dock-max-h/);
|
||||
assert.match(html, /Your thinking/);
|
||||
assert.match(html, /data-testid="practice-nudge"/);
|
||||
assert.match(html, /data-testid="practice-commit"/);
|
||||
assert.match(html, /data-testid="practice-send"/);
|
||||
assert.ok(!html.includes("practice-nudge") && !html.includes("practice-commit"), "the two-lane buttons are gone");
|
||||
// The buttons carry their lane in the disabled gate; both start
|
||||
// disabled (empty field).
|
||||
assert.match(html, /disabled/);
|
||||
@@ -142,12 +142,14 @@ describe("the practice transcript cards", () => {
|
||||
assert.doesNotMatch(html, /The solution/);
|
||||
});
|
||||
|
||||
it("ThinkingCard: the register is the title (trace vs solution)", () => {
|
||||
it("ThinkingCard: one label, collapsed arrival (legacy commit flags ignored)", () => {
|
||||
const trace = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: false, text: "stuck at step two" }} />);
|
||||
assert.match(trace, /Your thinking/);
|
||||
assert.doesNotMatch(trace, /Your solution/);
|
||||
const commit = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: true, text: "It is 42." }} />);
|
||||
assert.match(commit, /Your solution/);
|
||||
assert.match(trace, /aria-expanded="false"/);
|
||||
assert.ok(!trace.includes("stuck at step two"), "the text is one click away");
|
||||
const legacy = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: true, text: "It is 42." }} />);
|
||||
assert.match(legacy, /Your thinking/);
|
||||
assert.doesNotMatch(legacy, /Your solution/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,13 +157,13 @@ describe("the practice transcript cards", () => {
|
||||
|
||||
describe("the practice view projection", () => {
|
||||
it("the thinking marker projects as a user-side data part (register in details)", () => {
|
||||
const marker = newThinkingMessage("call_p1", true, "It is 42.");
|
||||
const marker = newThinkingMessage("call_p1", "It is 42.");
|
||||
const view = projectView([marker as LhMessage]);
|
||||
assert.equal(view.length, 1);
|
||||
assert.equal(view[0]!.role, "user");
|
||||
const dataPart = view[0]!.parts[0]!;
|
||||
assert.equal(dataPart.type, "data-practice-thinking");
|
||||
assert.deepEqual(dataPart.data, { problemCallId: "call_p1", commit: true, text: "It is 42." });
|
||||
assert.deepEqual(dataPart.data, { problemCallId: "call_p1", text: "It is 42." });
|
||||
});
|
||||
|
||||
it("the problem pose's ack keeps the part input-available (the dock's mount)", () => {
|
||||
|
||||
@@ -1012,11 +1012,11 @@ describe("the live-view law — live is positive evidence, not session busyness"
|
||||
|
||||
describe("absorbed — the custom role (the doubled thinking card)", () => {
|
||||
it("absorbs the optimistic thinking marker by customType + details", () => {
|
||||
const optimistic = { ...newThinkingMessage("call-p1", false, "my trace"), id: "c_opt1" } as LhMessage;
|
||||
const serverCopy = { ...newThinkingMessage("call-p1", false, "my trace"), id: "srv_1" } as LhMessage;
|
||||
const optimistic = { ...newThinkingMessage("call-p1", "my trace"), id: "c_opt1" } as LhMessage;
|
||||
const serverCopy = { ...newThinkingMessage("call-p1", "my trace"), id: "srv_1" } as LhMessage;
|
||||
assert.ok(absorbed(optimistic, [...poseRecord(), serverCopy]), "same submission → the local copy drops");
|
||||
// A DIFFERENT submission (commit flag, text) is its own marker.
|
||||
const other = { ...newThinkingMessage("call-p1", true, "my trace"), id: "srv_2" } as LhMessage;
|
||||
// A DIFFERENT submission (different text) is its own marker.
|
||||
const other = { ...newThinkingMessage("call-p1", "a later, different trace"), id: "srv_2" } as LhMessage;
|
||||
assert.ok(!absorbed(optimistic, [...poseRecord(), other]), "different details → not the same submission");
|
||||
// The c_ gate: server stamps are never absorbed, whatever the role.
|
||||
assert.ok(!absorbed(serverCopy, [optimistic]), "server ids never drop");
|
||||
@@ -1027,14 +1027,14 @@ describe("absorbed — the custom role (the doubled thinking card)", () => {
|
||||
// the tail (client id), then the +400ms adoption fetch swaps in the
|
||||
// write-ahead record (server id). ONE card must remain.
|
||||
const m = model({ chatId: "chat_x", initialMessages: poseRecord() });
|
||||
const optimistic = { ...newThinkingMessage("call-p1", false, "my trace"), id: "c_opt1" } as LhMessage;
|
||||
const optimistic = { ...newThinkingMessage("call-p1", "my trace"), id: "c_opt1" } as LhMessage;
|
||||
m.applyWireEvent({ type: "message_start", message: optimistic });
|
||||
assert.equal(
|
||||
m.getState().nativeMessages.filter((x) => (x as { role?: string }).role === "custom").length,
|
||||
1,
|
||||
"the optimistic marker is in the tail",
|
||||
);
|
||||
const serverCopy = { ...newThinkingMessage("call-p1", false, "my trace"), id: "srv_1" } as LhMessage;
|
||||
const serverCopy = { ...newThinkingMessage("call-p1", "my trace"), id: "srv_1" } as LhMessage;
|
||||
m.adoptRecord([...poseRecord(), serverCopy]);
|
||||
const customs = m.getState().nativeMessages.filter((x) => (x as { role?: string }).role === "custom");
|
||||
assert.equal(customs.length, 1, "the server copy replaced the optimistic one — no doubling");
|
||||
|
||||
Reference in New Issue
Block a user