practice polish: no phantom wake, Practice header tag, honest meta
- 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).
This commit is contained in:
@@ -24,6 +24,7 @@ export default async function ChatPage({
|
|||||||
key={chat.id}
|
key={chat.id}
|
||||||
chatId={chat.id}
|
chatId={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
|
kind={chat.kind ?? "teach"}
|
||||||
// Wave D: the record IS native agent history — the client view is
|
// Wave D: the record IS native agent history — the client view is
|
||||||
// derived app-side (core/view.ts projectView; GET serves the same
|
// derived app-side (core/view.ts projectView; GET serves the same
|
||||||
// native messages).
|
// native messages).
|
||||||
|
|||||||
+25
-3
@@ -33,7 +33,7 @@ import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
|||||||
import { PlanMarginalia, planExists } from "@/components/plan-window";
|
import { PlanMarginalia, planExists } from "@/components/plan-window";
|
||||||
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
||||||
import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog";
|
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 type { PlanBlockData } from "@/src/core";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
import { QuestionDock } from "@/components/question-dock";
|
||||||
import { PracticeDock } from "@/components/practice-dock";
|
import { PracticeDock } from "@/components/practice-dock";
|
||||||
@@ -176,10 +176,15 @@ export function Chat({
|
|||||||
initialPrompt,
|
initialPrompt,
|
||||||
initiallyInFlight,
|
initiallyInFlight,
|
||||||
initialConfigured = false,
|
initialConfigured = false,
|
||||||
|
kind = "teach",
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
initialMessages: LhMessage[];
|
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;
|
initialPrompt?: string;
|
||||||
/** A teacher turn is running server-side but attached to no client
|
/** A teacher turn is running server-side but attached to no client
|
||||||
* (navigated away mid-turn, returned while in flight). */
|
* (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
|
with uncovered vs the chunk threshold, pulses while a run is in
|
||||||
flight, vanishes when the graph is off. Click → the exact
|
flight, vanishes when the graph is off. Click → the exact
|
||||||
numbers. Before the phase label — the chips cluster right. */}
|
numbers. Before the phase label — the chips cluster right. */}
|
||||||
<CoverageRing chatId={chatId} refreshKey={coverageTick} />
|
{/* Practice (owner, 2026-10-04): the coverage ring hides —
|
||||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
practice never extracts, so an ever-filling ring with no
|
||||||
|
run behind it would lie. */}
|
||||||
|
{kind !== "practice" && <CoverageRing chatId={chatId} refreshKey={coverageTick} />}
|
||||||
|
{/* Practice sessions: no phase arc — a quiet Practice tag
|
||||||
|
instead of the phase chip. */}
|
||||||
|
{kind === "practice" ? (
|
||||||
|
<span
|
||||||
|
data-testid="phase-chip"
|
||||||
|
data-phase="practice"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none flex shrink-0 select-none items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
<DumbbellIcon className="size-3" />
|
||||||
|
Practice
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||||
|
)}
|
||||||
</TopBar>
|
</TopBar>
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
|
|||||||
+11
-1
@@ -187,7 +187,7 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
|
|||||||
the chain table as the picker. Quiet, left-aligned above
|
the chain table as the picker. Quiet, left-aligned above
|
||||||
the composer; teaching mode renders nothing. */}
|
the composer; teaching mode renders nothing. */}
|
||||||
{mode === "practice" && (
|
{mode === "practice" && (
|
||||||
<div className="mb-2 flex justify-start">
|
<div className="mb-2 flex items-center justify-start gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="practice-props-chip"
|
data-testid="practice-props-chip"
|
||||||
@@ -199,6 +199,16 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
|
|||||||
? `${practiceProps.length} ${practiceProps.length === 1 ? "proposition" : "propositions"} selected`
|
? `${practiceProps.length} ${practiceProps.length === 1 ? "proposition" : "propositions"} selected`
|
||||||
: "Pick propositions from the map"}
|
: "Pick propositions from the map"}
|
||||||
</button>
|
</button>
|
||||||
|
{practiceProps.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="practice-props-clear"
|
||||||
|
onClick={() => setPracticeProps([])}
|
||||||
|
className="rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{zeroKeys ? (
|
{zeroKeys ? (
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ export function timeAgo(iso: string, now = new Date()): string {
|
|||||||
* ever wanted. */
|
* ever wanted. */
|
||||||
function sessionMeta(chat: ChatListItem): string {
|
function sessionMeta(chat: ChatListItem): string {
|
||||||
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
|
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…";
|
if (chat.busy) return (chat.kind ?? "teach") === "practice" ? "Practicing…" : "Teacher is working…";
|
||||||
return timeAgo(chat.updatedAt);
|
return timeAgo(chat.updatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export interface ChatSummary {
|
|||||||
phase: Phase3;
|
phase: Phase3;
|
||||||
/** An unanswered quiz/ask question is waiting for the learner. */
|
/** An unanswered quiz/ask question is waiting for the learner. */
|
||||||
pendingQuestion: boolean;
|
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
|
/** The record's model-control settings — rides the list payload via the
|
||||||
* summary spread (the picker never needs it from here; the full record
|
* summary spread (the picker never needs it from here; the full record
|
||||||
* is its source). */
|
* is its source). */
|
||||||
|
|||||||
@@ -515,9 +515,18 @@ export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
|||||||
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
||||||
const content = (owner as { content?: unknown }).content;
|
const content = (owner as { content?: unknown }).content;
|
||||||
if (!Array.isArray(content)) return false;
|
if (!Array.isArray(content)) return false;
|
||||||
const answered = new Set(
|
const results = messages.slice(start) as ToolResultMessage[];
|
||||||
messages.slice(start).map((m) => (m as ToolResultMessage).toolCallId),
|
// 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(
|
const calls = (content as Array<{ type?: string; id?: string }>).filter(
|
||||||
(b) => b.type === "toolCall" && typeof b.id === "string",
|
(b) => b.type === "toolCall" && typeof b.id === "string",
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import { emit } from "../core/events.ts";
|
|||||||
import type { ChatStore } from "../core/ports.ts";
|
import type { ChatStore } from "../core/ports.ts";
|
||||||
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||||
import type { LhMessage } from "../core/native-messages.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";
|
import { phaseAt3Lh } from "../core/phases.ts";
|
||||||
|
|
||||||
function chatFile(id: string): string {
|
function chatFile(id: string): string {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
concludedByDismissalPair,
|
concludedByDismissalPair,
|
||||||
newProblemDismissedMarker,
|
newProblemDismissedMarker,
|
||||||
newThinkingMessage,
|
newThinkingMessage,
|
||||||
|
needsContinuation,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
pendingProblemDismissalOf,
|
pendingProblemDismissalOf,
|
||||||
toLlmMessages,
|
toLlmMessages,
|
||||||
@@ -262,3 +263,42 @@ describe("toLlmMessages (practice shapes)", () => {
|
|||||||
assert.equal(out[1]!.role, "toolResult");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function item(overrides: Partial<ChatListItem>): ChatListItem {
|
|||||||
messageCount: 4,
|
messageCount: 4,
|
||||||
phase: "probe",
|
phase: "probe",
|
||||||
pendingQuestion: false,
|
pendingQuestion: false,
|
||||||
|
pendingProblem: false,
|
||||||
busy: false,
|
busy: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -135,4 +136,9 @@ describe("SessionRow practice badge (practice sessions, 2026-10-04)", () => {
|
|||||||
const html = render(item({ busy: true }));
|
const html = render(item({ busy: true }));
|
||||||
assert.match(html, /Teacher is working…/);
|
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 ·/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user