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:
@@ -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