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:
amosblomqvist
2026-10-05 03:46:32 +03:00
parent f460a90be9
commit 0bd3236af0
13 changed files with 101 additions and 130 deletions
+8 -8
View File
@@ -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
+6 -13
View File
@@ -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} />;
}
+22 -36
View File
@@ -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>
+3 -3
View File
@@ -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() });
+3 -3
View File
@@ -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.
+4 -4
View File
@@ -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.
+4 -2
View File
@@ -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
+5 -8
View File
@@ -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)];
}
}
}
+9 -10
View File
@@ -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(),
};
}
+9 -8
View File
@@ -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");
});
+10 -19
View File
@@ -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)", () => {
+12 -10
View File
@@ -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)", () => {
+6 -6
View File
@@ -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");