From 9362baf36ede40432d3956465e25c67f2b79204c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 4 Oct 2026 22:06:22 +0300 Subject: [PATCH] practice polish: no phantom wake, Practice header tag, honest meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - needsContinuation: a persisted problem-pose ack (terminate:true ended the turn deliberately) owes no continuation — reloads after a pose no longer wake a phantom turn; error results stay owed like any interrupted tool. - Chat header: practice sessions swap the phase chip for a quiet Practice tag (no probe→teach arc applies) and hide the coverage ring (practice never extracts — an ever-filling ring would lie). - ChatSummary gains pendingProblem (activeProblemOf at projection); the sidebar meta says 'Problem waiting' for a practice row with a posed problem. Kind threads through Chat/[id] page. - Home picker chip gains Clear (picks were otherwise irremovable). --- app/chat/[id]/page.tsx | 1 + components/chat.tsx | 28 +++++++++++++++++++++--- components/home.tsx | 12 ++++++++++- components/sidebar.tsx | 3 +++ src/core/contracts/chat.ts | 3 +++ src/core/native-messages.ts | 13 ++++++++++-- src/store/chats.ts | 2 +- tests/practice-record.test.ts | 40 +++++++++++++++++++++++++++++++++++ tests/render-sidebar.test.tsx | 6 ++++++ 9 files changed, 101 insertions(+), 7 deletions(-) diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 18d6989..8d012e0 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -24,6 +24,7 @@ export default async function ChatPage({ key={chat.id} chatId={chat.id} title={chat.title} + kind={chat.kind ?? "teach"} // Wave D: the record IS native agent history — the client view is // derived app-side (core/view.ts projectView; GET serves the same // native messages). diff --git a/components/chat.tsx b/components/chat.tsx index cd0e596..1c0a80e 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -33,7 +33,7 @@ import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia, planExists } from "@/components/plan-window"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog"; -import { WaypointsIcon } from "lucide-react"; +import { DumbbellIcon, WaypointsIcon } from "lucide-react"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { PracticeDock } from "@/components/practice-dock"; @@ -176,10 +176,15 @@ export function Chat({ initialPrompt, initiallyInFlight, initialConfigured = false, + kind = "teach", }: { chatId: string; title: string; initialMessages: LhMessage[]; + /** The session kind (practice sessions, owner 2026-10-04): practice + * renders no phase chip (no probe→teach arc applies) and a Practice + * tag rides the header instead. */ + kind?: "teach" | "practice"; initialPrompt?: string; /** A teacher turn is running server-side but attached to no client * (navigated away mid-turn, returned while in flight). */ @@ -1146,8 +1151,25 @@ export function Chat({ with uncovered vs the chunk threshold, pulses while a run is in flight, vanishes when the graph is off. Click → the exact numbers. Before the phase label — the chips cluster right. */} - - + {/* Practice (owner, 2026-10-04): the coverage ring hides — + practice never extracts, so an ever-filling ring with no + run behind it would lie. */} + {kind !== "practice" && } + {/* Practice sessions: no phase arc — a quiet Practice tag + instead of the phase chip. */} + {kind === "practice" ? ( + + ) : ( + + )}
diff --git a/components/home.tsx b/components/home.tsx index bbb28d0..f1c3b49 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -187,7 +187,7 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { the chain table as the picker. Quiet, left-aligned above the composer; teaching mode renders nothing. */} {mode === "practice" && ( -
+
+ )}
)} {zeroKeys ? ( diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 43bed5b..60d3b22 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -227,6 +227,9 @@ export function timeAgo(iso: string, now = new Date()): string { * ever wanted. */ function sessionMeta(chat: ChatListItem): string { if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`; + if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) { + return `Problem waiting · ${timeAgo(chat.updatedAt)}`; + } if (chat.busy) return (chat.kind ?? "teach") === "practice" ? "Practicing…" : "Teacher is working…"; return timeAgo(chat.updatedAt); } diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index a3a4007..4ca0b83 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -233,6 +233,9 @@ export interface ChatSummary { phase: Phase3; /** An unanswered quiz/ask question is waiting for the learner. */ pendingQuestion: boolean; + /** Practice sessions (2026-10-04): a posed problem with no conclude — + * the sidebar meta's "Problem waiting". Absent on teaching sessions. */ + pendingProblem: boolean; /** The record's model-control settings — rides the list payload via the * summary spread (the picker never needs it from here; the full record * is its source). */ diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index d3cebee..01cd048 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -515,9 +515,18 @@ export function needsContinuation(messages: readonly LhMessage[]): boolean { if (!owner || (owner as { role?: string }).role !== "assistant") return false; const content = (owner as { content?: unknown }).content; if (!Array.isArray(content)) return false; - const answered = new Set( - messages.slice(start).map((m) => (m as ToolResultMessage).toolCallId), + const results = messages.slice(start) as ToolResultMessage[]; + // PRACTICE (owner design, 2026-10-04): a batch acking a problem POSE + // owes nothing — terminate:true ended that turn DELIBERATELY (the + // learner's next thinking submission is the next turn), and unlike a + // question's placeholder this ack PERSISTS (the model reads its own + // "Problem posed" result later). Without this fold a reload after a + // pose would wake a phantom continuation turn. + const posedProblem = results.some( + (m) => m.toolName === PRACTICE_PROBLEM_TOOL && !m.isError && m.details == null, ); + if (posedProblem) return false; + const answered = new Set(results.map((m) => m.toolCallId)); const calls = (content as Array<{ type?: string; id?: string }>).filter( (b) => b.type === "toolCall" && typeof b.id === "string", ); diff --git a/src/store/chats.ts b/src/store/chats.ts index 72d7210..b062f36 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -14,7 +14,7 @@ import { emit } from "../core/events.ts"; import type { ChatStore } from "../core/ports.ts"; import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts"; import type { LhMessage } from "../core/native-messages.ts"; -import { danglingQuestionOf } from "../core/native-messages.ts"; +import { activeProblemOf, danglingQuestionOf } from "../core/native-messages.ts"; import { phaseAt3Lh } from "../core/phases.ts"; function chatFile(id: string): string { diff --git a/tests/practice-record.test.ts b/tests/practice-record.test.ts index 629161c..49c8761 100644 --- a/tests/practice-record.test.ts +++ b/tests/practice-record.test.ts @@ -17,6 +17,7 @@ import { concludedByDismissalPair, newProblemDismissedMarker, newThinkingMessage, + needsContinuation, newUserMessage, pendingProblemDismissalOf, toLlmMessages, @@ -262,3 +263,42 @@ describe("toLlmMessages (practice shapes)", () => { assert.equal(out[1]!.role, "toolResult"); }); }); + +// ── the continuation fold's practice carve-out ────────────────────────────── + +describe("needsContinuation (a persisted pose ack owes nothing)", () => { + it("a record ending on a problem-pose ack does NOT wake a continuation", () => { + // The pose's terminate:true ended that turn deliberately; the + // ack persists (unlike a question's placeholder). Without the + // carve-out, a reload after a posed problem would wake a + // phantom turn (owner design, 2026-10-04). + assert.equal(needsContinuation(problemPose()), false); + }); + + it("a mixed batch (conclude ack then pose ack) still owes nothing — the pose is terminal", () => { + const messages = [ + ...problemPose("call_p0", "the earlier one"), + concludeCall("solved"), + { + id: "msg_r_conclude", + role: "toolResult", + toolCallId: "call_conclude_solved", + toolName: PRACTICE_CONCLUDE_TOOL, + content: [{ type: "text", text: CONCLUDE_ACK_TEXT }], + details: null, + isError: false, + timestamp: 4, + } as LhMessage & ToolResultMessage, + ...problemPose("call_p2", "the next one"), + ]; + assert.equal(needsContinuation(messages), false); + }); + + it("an ERROR result is not a pose — the agent loop continues past it, so a record ending there (an abort) is owed the wake like any interrupted tool", () => { + const messages = [...problemPose("call_pe")]; + const err = messages[1] as LhMessage & ToolResultMessage; + err.isError = true; + err.content = [{ type: "text", text: "A problem is already active." }]; + assert.equal(needsContinuation(messages), true); + }); +}); diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx index da0e8b8..25ca313 100644 --- a/tests/render-sidebar.test.tsx +++ b/tests/render-sidebar.test.tsx @@ -27,6 +27,7 @@ function item(overrides: Partial): ChatListItem { messageCount: 4, phase: "probe", pendingQuestion: false, + pendingProblem: false, busy: false, ...overrides, }; @@ -135,4 +136,9 @@ describe("SessionRow practice badge (practice sessions, 2026-10-04)", () => { const html = render(item({ busy: true })); assert.match(html, /Teacher is working…/); }); + + it("a practice row with a posed problem says the problem is waiting", () => { + const html = render(item({ kind: "practice", pendingProblem: true })); + assert.match(html, /Problem waiting ·/); + }); });