diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 08369e4..34aa713 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -17,9 +17,12 @@ export async function GET() { } export async function POST(req: Request) { - const body = (await req.json().catch(() => ({}))) as { title?: unknown }; + const body = (await req.json().catch(() => ({}))) as { title?: unknown; kind?: unknown }; const title = typeof body.title === "string" ? body.title : ""; - const chat = createChat(titleFromPrompt(title || "Untitled session")); + // Practice sessions (owner, 2026-10-04): kind "practice" marks the + // record — the practice agent + its tools from turn 1, no extraction. + const kind = body.kind === "practice" ? ("practice" as const) : undefined; + const chat = createChat(titleFromPrompt(title || "Untitled session"), kind); // PER-SESSION MODELS (owner, 2026-09-28): a session owns its model + // thinking level from birth — stamped verbatim from the STRONG slot, // the new-session default. From here the strong slot never reaches diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index dc5a3fb..64bb29b 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -21,12 +21,20 @@ import type { EmissionStats } from "./graph.ts"; * is the client's own grouping fold (core/view.ts, Wave D) — a pending quiz * is an unanswered question tool call, which is exactly what makes * resume-mid-question free. */ +/** The session's kind (practice sessions, owner 2026-10-04): "teach" is + * the default and ABSENT on pre-practice records (no migration — absence + * reads as teach); "practice" marks an adaptive-practice session — the + * practice agent, its two tools, no extraction, no graph writes. */ +export type ChatKind = "teach" | "practice"; + export interface ChatRecord { id: string; title: string; createdAt: string; updatedAt: string; messages: LhMessage[]; + /** The session's kind — absent = teach (pre-practice records). */ + kind?: ChatKind; /** The session's plan tree (vision §2) — written by plan tools via saveTree. */ tree?: PlanTree; /** Append-only usage log (observability, 2026-09): one entry per model @@ -202,6 +210,8 @@ export interface ChatSummary { title: string; createdAt: string; updatedAt: string; + /** Normalized kind (absent record field reads as teach). */ + kind: ChatKind; messageCount: number; /** The session's current phase, derived from the stored plan tree. */ phase: Phase3; diff --git a/src/core/ports.ts b/src/core/ports.ts index 185262b..ea67d93 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -32,7 +32,9 @@ import type { UserSettings } from "./settings.ts"; export interface ChatStore { /** Create a session record (persisted immediately). */ - createChat(title: string): ChatRecord; + /** Create a session record; kind "practice" marks a practice session + * (absent = teach, the default). */ + createChat(title: string, kind?: "practice"): ChatRecord; /** The full record, or null if unknown id / corrupt file. */ getChat(id: string): ChatRecord | null; /** All sessions, newest activity first (the sidebar's list view). */ diff --git a/src/store/chats.ts b/src/store/chats.ts index 9891959..72d7210 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -31,7 +31,7 @@ function chatFile(id: string): string { * phase, busy, pendingQuestion) is already announced by turn-started / * turn-settled, which the sidebar refetches on. Silence here is law, * not oversight. */ -function createChat(title: string): ChatRecord { +function createChat(title: string, kind?: "practice"): ChatRecord { const now = new Date().toISOString(); const record: ChatRecord = { id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`, @@ -39,6 +39,9 @@ function createChat(title: string): ChatRecord { createdAt: now, updatedAt: now, messages: [], + // Practice sessions carry the marker; teach stays ABSENT (absence + // reads as teach — pre-practice records never migrate). + ...(kind === "practice" ? { kind: "practice" as const } : {}), }; fs.mkdirSync(PATHS.chats, { recursive: true }); fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8"); @@ -73,6 +76,8 @@ function listChats(): ChatSummary[] { // summaries; usage aggregates ride the full record, the picker // never needs them). ...summary, + // Normalized kind: absent record field reads as teach. + kind: summary.kind ?? "teach", messageCount: messages.length, // Record-derived status for the session picker: phase from the // transcript's explicit events (probe_done concluded / verdict diff --git a/tests/chat-kind.test.ts b/tests/chat-kind.test.ts new file mode 100644 index 0000000..9f98881 --- /dev/null +++ b/tests/chat-kind.test.ts @@ -0,0 +1,46 @@ +/** + * SESSION-KIND PINS (practice sessions, owner 2026-10-04): ChatRecord.kind + * is ABSENT-BY-DEFAULT — absence reads as teach, so pre-practice records + * never migrate; "practice" marks an adaptive-practice session (the + * practice agent + its two tools from turn 1, no extraction, no graph + * writes). The store normalizes at the summary projection; createChat + * stamps the marker only for practice. Runs under an isolated LH_DATA_DIR + * (the chat-birth-stamp discipline: the seam is set before any + * config-loading import, so every app-module import is dynamic). + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; + +// ── the seam, BEFORE any config-loading import ────────────────────────────── +const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chat-kind-")); +process.env.LH_DATA_DIR = TMP_DATA; + +const { chatStore } = await import("../src/store/chats.ts"); + +after(() => { + fs.rmSync(TMP_DATA, { recursive: true, force: true }); +}); + +describe("session kind (practice sessions, 2026-10-04)", () => { + it("createChat default carries NO kind field — absence reads as teach", () => { + const chat = chatStore.createChat("plain session"); + assert.equal("kind" in chat, false, "teach records carry no kind marker"); + }); + + it("createChat(kind: 'practice') stamps the marker on record + list", () => { + const chat = chatStore.createChat("practice session", "practice"); + assert.equal(chat.kind, "practice"); + const listed = chatStore.listChats().find((c) => c.id === chat.id); + assert.equal(listed?.kind, "practice"); + }); + + it("the summary projection NORMALIZES: absent record field reads as teach", () => { + const chat = chatStore.createChat("another plain one"); + const listed = chatStore.listChats().find((c) => c.id === chat.id); + assert.equal(listed?.kind, "teach"); + }); +}); diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx index 681d47e..5b6b103 100644 --- a/tests/render-sidebar.test.tsx +++ b/tests/render-sidebar.test.tsx @@ -21,6 +21,7 @@ function item(overrides: Partial): ChatListItem { return { id: "c1", title: "Test session", + kind: "teach", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", messageCount: 4,