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" ? (
+
+
+ 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 ·/);
+ });
});